Compare commits
14 Commits
0f0f1cbf38
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 139cb4aebe | |||
| 14c6c9506f | |||
| 53eb82c4d2 | |||
| b80ffd3ca1 | |||
| cc3375ef85 | |||
| cb4a070ad9 | |||
| 12b9361ae0 | |||
| 33fda57cc6 | |||
| d93919910b | |||
| 4dbb899409 | |||
| 3bc9cd0bca | |||
| f2b3f34a72 | |||
| 806b67954e | |||
| a4d360aae9 |
@@ -0,0 +1,12 @@
|
||||
-- Delete duplicate rows keeping the first entry (by id) for each deviceId+timestamp pair
|
||||
DELETE FROM "gps_location_history" a
|
||||
USING "gps_location_history" b
|
||||
WHERE a."id" > b."id"
|
||||
AND a."deviceId" = b."deviceId"
|
||||
AND a."timestamp" = b."timestamp";
|
||||
|
||||
-- Drop the existing index that covered deviceId+timestamp (non-unique)
|
||||
DROP INDEX IF EXISTS "gps_location_history_deviceId_timestamp_idx";
|
||||
|
||||
-- CreateIndex (unique constraint replaces the old non-unique index)
|
||||
CREATE UNIQUE INDEX "gps_location_history_deviceId_timestamp_key" ON "gps_location_history"("deviceId", "timestamp");
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TripStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PROCESSING', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "gps_trips" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"status" "TripStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"startTime" TIMESTAMP(3) NOT NULL,
|
||||
"endTime" TIMESTAMP(3),
|
||||
"startLatitude" DOUBLE PRECISION NOT NULL,
|
||||
"startLongitude" DOUBLE PRECISION NOT NULL,
|
||||
"endLatitude" DOUBLE PRECISION,
|
||||
"endLongitude" DOUBLE PRECISION,
|
||||
"distanceMiles" DOUBLE PRECISION,
|
||||
"durationSeconds" INTEGER,
|
||||
"topSpeedMph" DOUBLE PRECISION,
|
||||
"averageSpeedMph" DOUBLE PRECISION,
|
||||
"pointCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"matchedRoute" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "gps_trips_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gps_trips_deviceId_startTime_idx" ON "gps_trips"("deviceId", "startTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gps_trips_status_idx" ON "gps_trips"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gps_trips" ADD CONSTRAINT "gps_trips_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
1
backend/src/common/pipes/index.ts
Normal file
1
backend/src/common/pipes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './parse-boolean.pipe';
|
||||
49
backend/src/common/pipes/parse-boolean.pipe.ts
Normal file
49
backend/src/common/pipes/parse-boolean.pipe.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Transforms query string values to proper booleans.
|
||||
*
|
||||
* Handles common boolean string representations:
|
||||
* - 'true', '1', 'yes', 'on' → true
|
||||
* - 'false', '0', 'no', 'off' → false
|
||||
* - undefined, null, '' → false (default)
|
||||
* - Any other value → BadRequestException
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Delete(':id')
|
||||
* async remove(
|
||||
* @Param('id') id: string,
|
||||
* @Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
* ) {
|
||||
* return this.service.remove(id, hard);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class ParseBooleanPipe implements PipeTransform<string | undefined, boolean> {
|
||||
transform(value: string | undefined): boolean {
|
||||
// Handle undefined, null, or empty string as false (default)
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize to lowercase for comparison
|
||||
const normalized = value.toLowerCase().trim();
|
||||
|
||||
// True values
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// False values
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Invalid value
|
||||
throw new BadRequestException(
|
||||
`Invalid boolean value: "${value}". Expected: true, false, 1, 0, yes, no, on, off`,
|
||||
);
|
||||
}
|
||||
}
|
||||
99
backend/src/common/utils/date.utils.ts
Normal file
99
backend/src/common/utils/date.utils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Date utility functions to consolidate common date manipulation patterns
|
||||
* across the VIP Coordinator application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a Date object to ISO date string format (YYYY-MM-DD).
|
||||
* Replaces the repetitive pattern: date.toISOString().split('T')[0]
|
||||
*
|
||||
* @param date - The date to convert
|
||||
* @returns ISO date string in YYYY-MM-DD format
|
||||
*
|
||||
* @example
|
||||
* const dateStr = toDateString(new Date('2024-01-15T10:30:00Z'));
|
||||
* // Returns: '2024-01-15'
|
||||
*/
|
||||
export function toDateString(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Date object to the start of the day (00:00:00.000).
|
||||
* Replaces the pattern: date.setHours(0, 0, 0, 0)
|
||||
*
|
||||
* @param date - The date to normalize
|
||||
* @returns A new Date object set to the start of the day
|
||||
*
|
||||
* @example
|
||||
* const dayStart = startOfDay(new Date('2024-01-15T15:45:30Z'));
|
||||
* // Returns: Date object at 2024-01-15T00:00:00.000
|
||||
*/
|
||||
export function startOfDay(date: Date): Date {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Date object to the end of the day (23:59:59.999).
|
||||
*
|
||||
* @param date - The date to normalize
|
||||
* @returns A new Date object set to the end of the day
|
||||
*
|
||||
* @example
|
||||
* const dayEnd = endOfDay(new Date('2024-01-15T10:30:00Z'));
|
||||
* // Returns: Date object at 2024-01-15T23:59:59.999
|
||||
*/
|
||||
export function endOfDay(date: Date): Date {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(23, 59, 59, 999);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts optional date string fields to Date objects for multiple fields at once.
|
||||
* Useful for DTO to Prisma data transformation where only provided fields should be converted.
|
||||
*
|
||||
* @param obj - The object containing date string fields
|
||||
* @param fields - Array of field names that should be converted to Date objects if present
|
||||
* @returns New object with specified fields converted to Date objects
|
||||
*
|
||||
* @example
|
||||
* const dto = {
|
||||
* name: 'Flight 123',
|
||||
* scheduledDeparture: '2024-01-15T10:00:00Z',
|
||||
* scheduledArrival: '2024-01-15T12:00:00Z',
|
||||
* actualDeparture: undefined,
|
||||
* };
|
||||
*
|
||||
* const data = convertOptionalDates(dto, [
|
||||
* 'scheduledDeparture',
|
||||
* 'scheduledArrival',
|
||||
* 'actualDeparture',
|
||||
* 'actualArrival'
|
||||
* ]);
|
||||
*
|
||||
* // Result: {
|
||||
* // name: 'Flight 123',
|
||||
* // scheduledDeparture: Date object,
|
||||
* // scheduledArrival: Date object,
|
||||
* // actualDeparture: undefined,
|
||||
* // actualArrival: undefined
|
||||
* // }
|
||||
*/
|
||||
export function convertOptionalDates<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
fields: (keyof T)[],
|
||||
): T {
|
||||
const result = { ...obj };
|
||||
|
||||
for (const field of fields) {
|
||||
const value = obj[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
result[field] = new Date(value as any) as any;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
78
backend/src/common/utils/hard-delete.utils.ts
Normal file
78
backend/src/common/utils/hard-delete.utils.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ForbiddenException, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Enforces hard-delete authorization and executes the appropriate delete operation.
|
||||
*
|
||||
* @param options Configuration object
|
||||
* @param options.id Entity ID to delete
|
||||
* @param options.hardDelete Whether to perform hard delete (true) or soft delete (false)
|
||||
* @param options.userRole User's role (required for hard delete authorization)
|
||||
* @param options.findOne Function to find and verify entity exists
|
||||
* @param options.performHardDelete Function to perform hard delete (e.g., prisma.model.delete)
|
||||
* @param options.performSoftDelete Function to perform soft delete (e.g., prisma.model.update)
|
||||
* @param options.entityName Name of entity for logging (e.g., 'VIP', 'Driver')
|
||||
* @param options.logger Logger instance for the service
|
||||
* @returns Promise resolving to the deleted entity
|
||||
* @throws {ForbiddenException} If non-admin attempts hard delete
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* async remove(id: string, hardDelete = false, userRole?: string) {
|
||||
* return executeHardDelete({
|
||||
* id,
|
||||
* hardDelete,
|
||||
* userRole,
|
||||
* findOne: async (id) => this.findOne(id),
|
||||
* performHardDelete: async (id) => this.prisma.vIP.delete({ where: { id } }),
|
||||
* performSoftDelete: async (id) => this.prisma.vIP.update({
|
||||
* where: { id },
|
||||
* data: { deletedAt: new Date() },
|
||||
* }),
|
||||
* entityName: 'VIP',
|
||||
* logger: this.logger,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function executeHardDelete<T>(options: {
|
||||
id: string;
|
||||
hardDelete: boolean;
|
||||
userRole?: string;
|
||||
findOne: (id: string) => Promise<T & { id: string; name?: string }>;
|
||||
performHardDelete: (id: string) => Promise<any>;
|
||||
performSoftDelete: (id: string) => Promise<any>;
|
||||
entityName: string;
|
||||
logger: Logger;
|
||||
}): Promise<any> {
|
||||
const {
|
||||
id,
|
||||
hardDelete,
|
||||
userRole,
|
||||
findOne,
|
||||
performHardDelete,
|
||||
performSoftDelete,
|
||||
entityName,
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
// Authorization check: only administrators can hard delete
|
||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
||||
throw new ForbiddenException(
|
||||
'Only administrators can permanently delete records',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify entity exists
|
||||
const entity = await findOne(id);
|
||||
|
||||
// Perform the appropriate delete operation
|
||||
if (hardDelete) {
|
||||
const entityLabel = entity.name || entity.id;
|
||||
logger.log(`Hard deleting ${entityName}: ${entityLabel}`);
|
||||
return performHardDelete(entity.id);
|
||||
}
|
||||
|
||||
const entityLabel = entity.name || entity.id;
|
||||
logger.log(`Soft deleting ${entityName}: ${entityLabel}`);
|
||||
return performSoftDelete(entity.id);
|
||||
}
|
||||
7
backend/src/common/utils/index.ts
Normal file
7
backend/src/common/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Common utility functions used throughout the application.
|
||||
* Export all utilities from this central location for easier imports.
|
||||
*/
|
||||
|
||||
export * from './date.utils';
|
||||
export * from './hard-delete.utils';
|
||||
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotFleetService {
|
||||
private readonly logger = new Logger(CopilotFleetService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAvailableVehicles(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null, status: 'AVAILABLE' };
|
||||
|
||||
if (filters.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
|
||||
if (filters.minSeats) {
|
||||
where.seatCapacity = { gte: filters.minSeats };
|
||||
}
|
||||
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: vehicles,
|
||||
message: `Found ${vehicles.length} available vehicle(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async assignVehicleToEvent(eventId: string, vehicleId: string): Promise<ToolResult> {
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// If vehicleId is null, we're unassigning
|
||||
if (vehicleId === null || vehicleId === 'null') {
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId: null },
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle unassigned from event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify vehicle exists
|
||||
const vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: `Vehicle with ID ${vehicleId} not found.` };
|
||||
}
|
||||
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId },
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
async suggestVehicleForEvent(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { eventId } = input;
|
||||
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// Fetch VIP info to determine party size
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds } },
|
||||
select: { id: true, name: true, partySize: true },
|
||||
});
|
||||
|
||||
// Determine required capacity based on total party size
|
||||
const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
// Find vehicles not in use during this event time
|
||||
const busyVehicleIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
id: { not: eventId },
|
||||
status: { not: 'CANCELLED' },
|
||||
vehicleId: { not: null },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: event.startTime },
|
||||
endTime: { gt: event.startTime },
|
||||
},
|
||||
{
|
||||
startTime: { lt: event.endTime },
|
||||
endTime: { gte: event.endTime },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { vehicleId: true },
|
||||
});
|
||||
|
||||
const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null);
|
||||
|
||||
// Find available vehicles with sufficient capacity
|
||||
const suitableVehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'AVAILABLE',
|
||||
seatCapacity: { gte: requiredSeats },
|
||||
id: { notIn: busyIds },
|
||||
},
|
||||
orderBy: [
|
||||
{ seatCapacity: 'asc' }, // Prefer smallest suitable vehicle
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
eventId,
|
||||
eventTitle: event.title,
|
||||
vipNames: vips.map((v) => v.name),
|
||||
requiredSeats,
|
||||
suggestions: suitableVehicles.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
seatCapacity: v.seatCapacity,
|
||||
})),
|
||||
},
|
||||
message:
|
||||
suitableVehicles.length > 0
|
||||
? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).`
|
||||
: `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getVehicleSchedule(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vehicleName, vehicleId, startDate, endDate } = input;
|
||||
|
||||
let vehicle;
|
||||
|
||||
if (vehicleId) {
|
||||
vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
} else if (vehicleName) {
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
name: { contains: vehicleName, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
|
||||
if (vehicles.length === 0) {
|
||||
return { success: false, error: `No vehicle found matching "${vehicleName}".` };
|
||||
}
|
||||
|
||||
if (vehicles.length > 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`,
|
||||
};
|
||||
}
|
||||
|
||||
vehicle = vehicles[0];
|
||||
} else {
|
||||
return { success: false, error: 'Either vehicleName or vehicleId is required.' };
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: 'Vehicle not found.' };
|
||||
}
|
||||
|
||||
const dateStart = startOfDay(new Date(startDate));
|
||||
const dateEnd = new Date(endDate);
|
||||
dateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vehicleId: vehicle.id,
|
||||
startTime: { gte: dateStart, lte: dateEnd },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const totalHours =
|
||||
events.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
vehicle: {
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
},
|
||||
dateRange: {
|
||||
start: toDateString(dateStart),
|
||||
end: toDateString(dateEnd),
|
||||
},
|
||||
eventCount: events.length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
events: events.map((e) => ({
|
||||
eventId: e.id,
|
||||
title: e.title,
|
||||
type: e.type,
|
||||
startTime: e.startTime,
|
||||
endTime: e.endTime,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
driverName: e.driver?.name || null,
|
||||
pickupLocation: e.pickupLocation,
|
||||
dropoffLocation: e.dropoffLocation,
|
||||
location: e.location,
|
||||
})),
|
||||
},
|
||||
message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`,
|
||||
};
|
||||
}
|
||||
|
||||
async searchDrivers(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (filters.name) {
|
||||
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (filters.department) {
|
||||
where.department = filters.department;
|
||||
}
|
||||
|
||||
if (filters.availableOnly) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) matching the criteria.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getDriverSchedule(
|
||||
driverId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): Promise<ToolResult> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const where: any = {
|
||||
deletedAt: null,
|
||||
driverId,
|
||||
status: { not: 'CANCELLED' },
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
where.startTime = { gte: new Date(startDate) };
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
where.endTime = { lte: new Date(endDate) };
|
||||
}
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where,
|
||||
include: {
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const eventsWithVipNames = events.map((event) => ({
|
||||
...event,
|
||||
vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
driver,
|
||||
events: eventsWithVipNames,
|
||||
eventCount: events.length,
|
||||
},
|
||||
message: `Driver ${driver.name} has ${events.length} scheduled event(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async listAllDrivers(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { includeUnavailable = true } = input;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (!includeUnavailable) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
department: true,
|
||||
isAvailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) in the system.`,
|
||||
};
|
||||
}
|
||||
|
||||
async findAvailableDriversForTimerange(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { startTime, endTime, preferredDepartment } = input;
|
||||
|
||||
// Get all drivers
|
||||
const where: any = { deletedAt: null, isAvailable: true };
|
||||
|
||||
if (preferredDepartment) {
|
||||
where.department = preferredDepartment;
|
||||
}
|
||||
|
||||
const allDrivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
});
|
||||
|
||||
// Find drivers with conflicting events
|
||||
const busyDriverIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
driverId: { not: null },
|
||||
status: { not: 'CANCELLED' },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: new Date(startTime) },
|
||||
endTime: { gt: new Date(startTime) },
|
||||
},
|
||||
{
|
||||
startTime: { lt: new Date(endTime) },
|
||||
endTime: { gte: new Date(endTime) },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { driverId: true },
|
||||
});
|
||||
|
||||
const busyIds = new Set(busyDriverIds.map((e) => e.driverId));
|
||||
|
||||
const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: availableDrivers,
|
||||
message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDriver(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { driverId, ...updates } = input;
|
||||
|
||||
const existingDriver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!existingDriver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.phone !== undefined) updateData.phone = updates.phone;
|
||||
if (updates.department !== undefined) updateData.department = updates.department;
|
||||
if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable;
|
||||
if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime;
|
||||
if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime;
|
||||
|
||||
const driver = await this.prisma.driver.update({
|
||||
where: { id: driverId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Driver updated: ${driverId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: driver,
|
||||
message: `Driver ${driver.name} updated successfully.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
304
backend/src/copilot/copilot-reports.service.ts
Normal file
304
backend/src/copilot/copilot-reports.service.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotReportsService {
|
||||
private readonly logger = new Logger(CopilotReportsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getDriverWorkloadSummary(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { startDate, endDate } = input;
|
||||
|
||||
const dateStart = startOfDay(new Date(startDate));
|
||||
const dateEnd = new Date(endDate);
|
||||
dateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Get all drivers
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Get all events in range
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: dateStart, lte: dateEnd },
|
||||
status: { not: 'CANCELLED' },
|
||||
driverId: { not: null },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate workload for each driver
|
||||
const workloadData = drivers.map((driver) => {
|
||||
const driverEvents = events.filter((e) => e.driverId === driver.id);
|
||||
|
||||
const totalHours =
|
||||
driverEvents.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
const totalDays = Math.ceil(
|
||||
(dateEnd.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
const eventsByType = driverEvents.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.type] = (acc[e.type] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
driverId: driver.id,
|
||||
driverName: driver.name,
|
||||
department: driver.department,
|
||||
isAvailable: driver.isAvailable,
|
||||
eventCount: driverEvents.length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
averageHoursPerDay: Math.round((totalHours / totalDays) * 10) / 10,
|
||||
eventsByType,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by total hours descending
|
||||
workloadData.sort((a, b) => b.totalHours - a.totalHours);
|
||||
|
||||
const totalEvents = events.length;
|
||||
const totalHours =
|
||||
events.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
dateRange: {
|
||||
start: toDateString(dateStart),
|
||||
end: toDateString(dateEnd),
|
||||
},
|
||||
summary: {
|
||||
totalDrivers: drivers.length,
|
||||
totalEvents,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
averageEventsPerDriver: Math.round((totalEvents / drivers.length) * 10) / 10,
|
||||
},
|
||||
driverWorkloads: workloadData,
|
||||
},
|
||||
message: `Workload summary for ${drivers.length} driver(s) from ${toDateString(dateStart)} to ${toDateString(dateEnd)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentSystemStatus(): Promise<ToolResult> {
|
||||
const now = new Date();
|
||||
const today = startOfDay(now);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
const [
|
||||
vipCount,
|
||||
vehicleCount,
|
||||
driverCount,
|
||||
todaysEvents,
|
||||
upcomingEvents,
|
||||
unassignedEvents,
|
||||
availableDrivers,
|
||||
availableVehicles,
|
||||
] = await Promise.all([
|
||||
this.prisma.vIP.count({ where: { deletedAt: null } }),
|
||||
this.prisma.vehicle.count({ where: { deletedAt: null } }),
|
||||
this.prisma.driver.count({ where: { deletedAt: null } }),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: today, lt: tomorrow },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
}),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: tomorrow, lt: nextWeek },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
}),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: now },
|
||||
status: { in: ['SCHEDULED'] },
|
||||
OR: [{ driverId: null }, { vehicleId: null }],
|
||||
},
|
||||
}),
|
||||
this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }),
|
||||
this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }),
|
||||
]);
|
||||
|
||||
const status = {
|
||||
timestamp: now.toISOString(),
|
||||
resources: {
|
||||
vips: vipCount,
|
||||
drivers: { total: driverCount, available: availableDrivers },
|
||||
vehicles: { total: vehicleCount, available: availableVehicles },
|
||||
},
|
||||
events: {
|
||||
today: todaysEvents,
|
||||
next7Days: upcomingEvents,
|
||||
needingAttention: unassignedEvents,
|
||||
},
|
||||
alerts: [] as string[],
|
||||
};
|
||||
|
||||
// Add alerts for issues
|
||||
if (unassignedEvents > 0) {
|
||||
status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`);
|
||||
}
|
||||
if (availableDrivers === 0) {
|
||||
status.alerts.push('No drivers currently marked as available');
|
||||
}
|
||||
if (availableVehicles === 0) {
|
||||
status.alerts.push('No vehicles currently available');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
message:
|
||||
status.alerts.length > 0
|
||||
? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.`
|
||||
: 'System status retrieved. No immediate issues.',
|
||||
};
|
||||
}
|
||||
|
||||
async getTodaysSummary(): Promise<ToolResult> {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Get today's events
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: today, lt: tomorrow },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
// Get VIPs arriving today (flights or self-driving)
|
||||
const arrivingVips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{
|
||||
expectedArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
{
|
||||
flights: {
|
||||
some: {
|
||||
scheduledArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
flights: {
|
||||
where: {
|
||||
scheduledArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
orderBy: { scheduledArrival: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get driver assignments
|
||||
const driversOnDuty = events
|
||||
.filter((e) => e.driver)
|
||||
.reduce((acc, e) => {
|
||||
if (e.driver && !acc.find((d) => d.id === e.driver!.id)) {
|
||||
acc.push(e.driver);
|
||||
}
|
||||
return acc;
|
||||
}, [] as NonNullable<typeof events[0]['driver']>[]);
|
||||
|
||||
// Unassigned events
|
||||
const unassigned = events.filter((e) => !e.driverId || !e.vehicleId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
date: toDateString(today),
|
||||
summary: {
|
||||
totalEvents: events.length,
|
||||
arrivingVips: arrivingVips.length,
|
||||
driversOnDuty: driversOnDuty.length,
|
||||
unassignedEvents: unassigned.length,
|
||||
},
|
||||
events: events.map((e) => ({
|
||||
id: e.id,
|
||||
time: e.startTime,
|
||||
title: e.title,
|
||||
type: e.type,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
driverName: e.driver?.name || 'UNASSIGNED',
|
||||
vehicleName: e.vehicle?.name || 'UNASSIGNED',
|
||||
location: e.location || e.pickupLocation,
|
||||
})),
|
||||
arrivingVips: arrivingVips.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
arrivalMode: v.arrivalMode,
|
||||
expectedArrival: v.expectedArrival,
|
||||
flights: v.flights.map((f) => ({
|
||||
flightNumber: f.flightNumber,
|
||||
scheduledArrival: f.scheduledArrival,
|
||||
arrivalAirport: f.arrivalAirport,
|
||||
})),
|
||||
})),
|
||||
driversOnDuty: driversOnDuty.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
eventCount: events.filter((e) => e.driverId === d.id).length,
|
||||
})),
|
||||
unassignedEvents: unassigned.map((e) => ({
|
||||
id: e.id,
|
||||
time: e.startTime,
|
||||
title: e.title,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
needsDriver: !e.driverId,
|
||||
needsVehicle: !e.vehicleId,
|
||||
})),
|
||||
},
|
||||
message: `Today's summary: ${events.length} event(s), ${arrivingVips.length} VIP(s) arriving, ${unassigned.length} unassigned.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
275
backend/src/copilot/copilot-vip.service.ts
Normal file
275
backend/src/copilot/copilot-vip.service.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotVipService {
|
||||
private readonly logger = new Logger(CopilotVipService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async searchVips(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (filters.name) {
|
||||
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||
}
|
||||
if (filters.organization) {
|
||||
where.organization = { contains: filters.organization, mode: 'insensitive' };
|
||||
}
|
||||
if (filters.department) {
|
||||
where.department = filters.department;
|
||||
}
|
||||
if (filters.arrivalMode) {
|
||||
where.arrivalMode = filters.arrivalMode;
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where,
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Fetch events for these VIPs
|
||||
const vipIds = vips.map((v) => v.id);
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vipIds: { hasSome: vipIds },
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Attach events to VIPs
|
||||
const vipsWithEvents = vips.map((vip) => ({
|
||||
...vip,
|
||||
events: events.filter((e) => e.vipIds.includes(vip.id)).slice(0, 5),
|
||||
}));
|
||||
|
||||
return { success: true, data: vipsWithEvents };
|
||||
}
|
||||
|
||||
async getVipDetails(vipId: string): Promise<ToolResult> {
|
||||
const vip = await this.prisma.vIP.findUnique({
|
||||
where: { id: vipId },
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vip) {
|
||||
return { success: false, error: 'VIP not found' };
|
||||
}
|
||||
|
||||
// Fetch events for this VIP
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vipIds: { has: vipId },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
return { success: true, data: { ...vip, events } };
|
||||
}
|
||||
|
||||
async createVip(input: Record<string, any>): Promise<ToolResult> {
|
||||
const vip = await this.prisma.vIP.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
organization: input.organization,
|
||||
department: input.department,
|
||||
arrivalMode: input.arrivalMode,
|
||||
expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null,
|
||||
airportPickup: input.airportPickup ?? false,
|
||||
venueTransport: input.venueTransport ?? false,
|
||||
partySize: input.partySize ?? 1,
|
||||
notes: input.notes,
|
||||
isRosterOnly: input.isRosterOnly ?? false,
|
||||
phone: input.phone || null,
|
||||
email: input.email || null,
|
||||
emergencyContactName: input.emergencyContactName || null,
|
||||
emergencyContactPhone: input.emergencyContactPhone || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: vip };
|
||||
}
|
||||
|
||||
async updateVip(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vipId, ...updateData } = input;
|
||||
|
||||
const data: any = {};
|
||||
if (updateData.name !== undefined) data.name = updateData.name;
|
||||
if (updateData.organization !== undefined) data.organization = updateData.organization;
|
||||
if (updateData.department !== undefined) data.department = updateData.department;
|
||||
if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode;
|
||||
if (updateData.expectedArrival !== undefined)
|
||||
data.expectedArrival = updateData.expectedArrival
|
||||
? new Date(updateData.expectedArrival)
|
||||
: null;
|
||||
if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup;
|
||||
if (updateData.venueTransport !== undefined)
|
||||
data.venueTransport = updateData.venueTransport;
|
||||
if (updateData.partySize !== undefined) data.partySize = updateData.partySize;
|
||||
if (updateData.notes !== undefined) data.notes = updateData.notes;
|
||||
if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly;
|
||||
if (updateData.phone !== undefined) data.phone = updateData.phone || null;
|
||||
if (updateData.email !== undefined) data.email = updateData.email || null;
|
||||
if (updateData.emergencyContactName !== undefined)
|
||||
data.emergencyContactName = updateData.emergencyContactName || null;
|
||||
if (updateData.emergencyContactPhone !== undefined)
|
||||
data.emergencyContactPhone = updateData.emergencyContactPhone || null;
|
||||
|
||||
const vip = await this.prisma.vIP.update({
|
||||
where: { id: vipId },
|
||||
data,
|
||||
include: { flights: true },
|
||||
});
|
||||
|
||||
return { success: true, data: vip };
|
||||
}
|
||||
|
||||
async getVipItinerary(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vipId, startDate, endDate } = input;
|
||||
|
||||
const vip = await this.prisma.vIP.findUnique({
|
||||
where: { id: vipId },
|
||||
});
|
||||
|
||||
if (!vip) {
|
||||
return { success: false, error: 'VIP not found' };
|
||||
}
|
||||
|
||||
// Build date filters
|
||||
const dateFilter: any = {};
|
||||
if (startDate) dateFilter.gte = new Date(startDate);
|
||||
if (endDate) dateFilter.lte = new Date(endDate);
|
||||
|
||||
// Get flights
|
||||
const flightsWhere: any = { vipId };
|
||||
if (startDate || endDate) {
|
||||
flightsWhere.flightDate = dateFilter;
|
||||
}
|
||||
const flights = await this.prisma.flight.findMany({
|
||||
where: flightsWhere,
|
||||
orderBy: { scheduledDeparture: 'asc' },
|
||||
});
|
||||
|
||||
// Get events
|
||||
const eventsWhere: any = {
|
||||
deletedAt: null,
|
||||
vipIds: { has: vipId },
|
||||
};
|
||||
if (startDate || endDate) {
|
||||
eventsWhere.startTime = dateFilter;
|
||||
}
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: eventsWhere,
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Combine and sort chronologically
|
||||
const itineraryItems: any[] = [
|
||||
...flights.map((f) => ({
|
||||
type: 'FLIGHT',
|
||||
time: f.scheduledDeparture || f.flightDate,
|
||||
data: f,
|
||||
})),
|
||||
...events.map((e) => ({
|
||||
type: 'EVENT',
|
||||
time: e.startTime,
|
||||
data: e,
|
||||
})),
|
||||
].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
vip,
|
||||
itinerary: itineraryItems,
|
||||
summary: {
|
||||
totalFlights: flights.length,
|
||||
totalEvents: events.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getFlightsForVip(vipId: string): Promise<ToolResult> {
|
||||
const flights = await this.prisma.flight.findMany({
|
||||
where: { vipId },
|
||||
orderBy: { flightDate: 'asc' },
|
||||
});
|
||||
|
||||
return { success: true, data: flights };
|
||||
}
|
||||
|
||||
async createFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||
const flight = await this.prisma.flight.create({
|
||||
data: {
|
||||
vipId: input.vipId,
|
||||
flightNumber: input.flightNumber,
|
||||
flightDate: new Date(input.flightDate),
|
||||
departureAirport: input.departureAirport,
|
||||
arrivalAirport: input.arrivalAirport,
|
||||
scheduledDeparture: input.scheduledDeparture
|
||||
? new Date(input.scheduledDeparture)
|
||||
: null,
|
||||
scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null,
|
||||
segment: input.segment || 1,
|
||||
},
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
return { success: true, data: flight };
|
||||
}
|
||||
|
||||
async updateFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { flightId, ...updateData } = input;
|
||||
|
||||
const flight = await this.prisma.flight.update({
|
||||
where: { id: flightId },
|
||||
data: updateData,
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
return { success: true, data: flight };
|
||||
}
|
||||
|
||||
async deleteFlight(flightId: string): Promise<ToolResult> {
|
||||
const flight = await this.prisma.flight.findUnique({
|
||||
where: { id: flightId },
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
if (!flight) {
|
||||
return { success: false, error: 'Flight not found' };
|
||||
}
|
||||
|
||||
await this.prisma.flight.delete({
|
||||
where: { id: flightId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: true, flight },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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
13
backend/src/drivers/decorators/current-driver.decorator.ts
Normal file
13
backend/src/drivers/decorators/current-driver.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Parameter decorator that extracts the current driver from the request.
|
||||
* Should be used in conjunction with @UseInterceptors(ResolveDriverInterceptor)
|
||||
* to ensure the driver is pre-resolved and attached to the request.
|
||||
*/
|
||||
export const CurrentDriver = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.driver;
|
||||
},
|
||||
);
|
||||
1
backend/src/drivers/decorators/index.ts
Normal file
1
backend/src/drivers/decorators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './current-driver.decorator';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
data: { deletedAt: new Date() },
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
backend/src/drivers/interceptors/index.ts
Normal file
1
backend/src/drivers/interceptors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './resolve-driver.interceptor';
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DriversService } from '../drivers.service';
|
||||
|
||||
/**
|
||||
* Interceptor that resolves the current driver from the authenticated user
|
||||
* and attaches it to the request object for /me routes.
|
||||
* This prevents multiple calls to findByUserId() in each route handler.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResolveDriverInterceptor implements NestInterceptor {
|
||||
constructor(private readonly driversService: DriversService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not authenticated');
|
||||
}
|
||||
|
||||
// Resolve driver from user ID and attach to request
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
|
||||
// Attach driver to request for use in route handlers
|
||||
request.driver = driver;
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
import * 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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
data: { deletedAt: new Date() },
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -33,6 +33,7 @@ export class DriverStatsDto {
|
||||
averageSpeedMph: number;
|
||||
totalTrips: number;
|
||||
totalDrivingMinutes: number;
|
||||
distanceMethod?: string; // 'osrm' or 'haversine'
|
||||
};
|
||||
recentLocations: LocationDataDto[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
// Get recent locations from our database
|
||||
// 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 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,39 +711,65 @@ 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;
|
||||
for (const device of devices) {
|
||||
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) },
|
||||
});
|
||||
|
||||
// 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),
|
||||
},
|
||||
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sync positions: ${error}`);
|
||||
}
|
||||
|
||||
this.logger.log('[GPS Sync] Sync completed');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
// Helper: create a flight record
|
||||
const makeFlight = (vipId: string, opts: any) => ({
|
||||
vipId,
|
||||
flightDate: new Date(),
|
||||
...opts,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 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',
|
||||
}));
|
||||
}
|
||||
|
||||
// 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 = 'scheduled';
|
||||
let status = df.statusOverride || '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';
|
||||
if (status === 'landed') {
|
||||
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
|
||||
}
|
||||
|
||||
flights.push({
|
||||
vipId: vip.id,
|
||||
flightNumber: flightNum,
|
||||
flightDate: new Date(),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
data: { deletedAt: new Date() },
|
||||
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,24 +91,33 @@ 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(),
|
||||
);
|
||||
|
||||
return {
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
upcomingTrips: upcomingEvents.length,
|
||||
currentDriver: vehicle.currentDriver?.name,
|
||||
};
|
||||
// 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' },
|
||||
});
|
||||
|
||||
const stats = vehicles.map((vehicle) => ({
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
upcomingTrips: vehicle.events.length, // Already filtered at DB level
|
||||
currentDriver: vehicle.currentDriver?.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
totalVehicles: vehicles.length,
|
||||
available: vehicles.filter((v) => v.status === 'AVAILABLE').length,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
data: { deletedAt: new Date() },
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Accountability Roster PDF Generator
|
||||
*
|
||||
* Professional roster document for emergency preparedness.
|
||||
* Follows VIPSchedulePDF patterns for consistent styling.
|
||||
*/
|
||||
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Font,
|
||||
Image,
|
||||
} from '@react-pdf/renderer';
|
||||
import { PdfSettings } from '@/types/settings';
|
||||
|
||||
Font.register({
|
||||
family: 'Helvetica',
|
||||
fonts: [
|
||||
{ src: 'Helvetica' },
|
||||
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
interface VIP {
|
||||
id: string;
|
||||
name: string;
|
||||
organization: string | null;
|
||||
department: string;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
isRosterOnly: boolean;
|
||||
partySize: number;
|
||||
}
|
||||
|
||||
interface AccountabilityRosterPDFProps {
|
||||
vips: VIP[];
|
||||
settings?: PdfSettings | null;
|
||||
}
|
||||
|
||||
const createStyles = (accentColor: string = '#2c3e50', _pageSize: 'LETTER' | 'A4' = 'LETTER') =>
|
||||
StyleSheet.create({
|
||||
page: {
|
||||
padding: 40,
|
||||
paddingBottom: 80,
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#333333',
|
||||
},
|
||||
|
||||
// Watermark
|
||||
watermark: {
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%) rotate(-45deg)',
|
||||
fontSize: 72,
|
||||
color: '#888888',
|
||||
opacity: 0.2,
|
||||
fontWeight: 'bold',
|
||||
zIndex: 0,
|
||||
},
|
||||
|
||||
// Logo
|
||||
logoContainer: {
|
||||
marginBottom: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
maxWidth: 130,
|
||||
maxHeight: 50,
|
||||
objectFit: 'contain',
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: 20,
|
||||
borderBottom: `2 solid ${accentColor}`,
|
||||
paddingBottom: 15,
|
||||
},
|
||||
orgName: {
|
||||
fontSize: 9,
|
||||
color: '#7f8c8d',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 2,
|
||||
marginBottom: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
customMessage: {
|
||||
fontSize: 9,
|
||||
color: '#7f8c8d',
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderLeft: `3 solid ${accentColor}`,
|
||||
},
|
||||
timestampBar: {
|
||||
marginTop: 10,
|
||||
paddingTop: 8,
|
||||
borderTop: '1 solid #ecf0f1',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 7,
|
||||
color: '#95a5a6',
|
||||
},
|
||||
|
||||
// Summary stats row
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 15,
|
||||
gap: 10,
|
||||
},
|
||||
summaryCard: {
|
||||
flex: 1,
|
||||
padding: 10,
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderLeft: `3 solid ${accentColor}`,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 8,
|
||||
color: '#7f8c8d',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Section
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
color: accentColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
paddingBottom: 4,
|
||||
borderBottom: `2 solid ${accentColor}`,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 18,
|
||||
},
|
||||
|
||||
// Table
|
||||
table: {
|
||||
borderLeft: '1 solid #dee2e6',
|
||||
borderRight: '1 solid #dee2e6',
|
||||
borderTop: '1 solid #dee2e6',
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: accentColor,
|
||||
minHeight: 24,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
color: '#ffffff',
|
||||
fontSize: 7,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
padding: 6,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottom: '1 solid #dee2e6',
|
||||
minHeight: 28,
|
||||
},
|
||||
tableRowAlt: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
},
|
||||
tableRowRoster: {
|
||||
backgroundColor: '#fef9e7',
|
||||
},
|
||||
tableRowRosterAlt: {
|
||||
backgroundColor: '#fdf3d0',
|
||||
},
|
||||
tableCell: {
|
||||
padding: 5,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cellName: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
},
|
||||
cellDept: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
marginTop: 1,
|
||||
},
|
||||
cellText: {
|
||||
fontSize: 8,
|
||||
color: '#34495e',
|
||||
},
|
||||
cellSmall: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
},
|
||||
cellCenter: {
|
||||
fontSize: 9,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
textAlign: 'center',
|
||||
},
|
||||
cellNoData: {
|
||||
fontSize: 7,
|
||||
color: '#bdc3c7',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
// Column widths
|
||||
colName: { width: '22%' },
|
||||
colOrg: { width: '18%' },
|
||||
colContact: { width: '22%' },
|
||||
colEmergency: { width: '22%' },
|
||||
colParty: { width: '8%' },
|
||||
colNotes: { width: '8%' },
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 25,
|
||||
left: 40,
|
||||
right: 40,
|
||||
paddingTop: 10,
|
||||
borderTop: '1 solid #dee2e6',
|
||||
},
|
||||
footerContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerLeft: {
|
||||
maxWidth: '60%',
|
||||
},
|
||||
footerTitle: {
|
||||
fontSize: 8,
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
marginBottom: 3,
|
||||
},
|
||||
footerContact: {
|
||||
fontSize: 7,
|
||||
color: '#7f8c8d',
|
||||
marginBottom: 1,
|
||||
},
|
||||
footerRight: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: 7,
|
||||
color: '#95a5a6',
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: 30,
|
||||
color: '#95a5a6',
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
|
||||
const formatDepartment = (dept: string) => {
|
||||
switch (dept) {
|
||||
case 'OFFICE_OF_DEVELOPMENT':
|
||||
return 'Office of Dev';
|
||||
case 'ADMIN':
|
||||
return 'Admin';
|
||||
default:
|
||||
return dept;
|
||||
}
|
||||
};
|
||||
|
||||
export function AccountabilityRosterPDF({
|
||||
vips,
|
||||
settings,
|
||||
}: AccountabilityRosterPDFProps) {
|
||||
const config = settings || {
|
||||
organizationName: 'VIP Transportation Services',
|
||||
accentColor: '#2c3e50',
|
||||
contactEmail: 'coordinator@example.com',
|
||||
contactPhone: '(555) 123-4567',
|
||||
contactLabel: 'Questions or Changes?',
|
||||
showDraftWatermark: false,
|
||||
showConfidentialWatermark: false,
|
||||
showTimestamp: true,
|
||||
showAppUrl: false,
|
||||
pageSize: 'LETTER' as const,
|
||||
logoUrl: null,
|
||||
tagline: null,
|
||||
headerMessage: null,
|
||||
footerMessage: null,
|
||||
secondaryContactName: null,
|
||||
secondaryContactPhone: null,
|
||||
};
|
||||
|
||||
const styles = createStyles(config.accentColor, config.pageSize);
|
||||
|
||||
const generatedAt = new Date().toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const activeVips = vips.filter((v) => !v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const rosterOnlyVips = vips.filter((v) => v.isRosterOnly).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const totalPeople = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
const activeCount = activeVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
const rosterCount = rosterOnlyVips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
const renderTableHeader = () => (
|
||||
<View style={styles.tableHeader}>
|
||||
<View style={[styles.tableHeaderCell, styles.colName]}>
|
||||
<Text>Name</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colOrg]}>
|
||||
<Text>Organization</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colContact]}>
|
||||
<Text>Contact</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colEmergency]}>
|
||||
<Text>Emergency Contact</Text>
|
||||
</View>
|
||||
<View style={[styles.tableHeaderCell, styles.colParty]}>
|
||||
<Text>Party</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderVipRow = (vip: VIP, index: number, isRoster: boolean) => (
|
||||
<View
|
||||
key={vip.id}
|
||||
style={[
|
||||
styles.tableRow,
|
||||
isRoster
|
||||
? index % 2 === 1 ? styles.tableRowRosterAlt : styles.tableRowRoster
|
||||
: index % 2 === 1 ? styles.tableRowAlt : {},
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
<View style={[styles.tableCell, styles.colName]}>
|
||||
<Text style={styles.cellName}>{vip.name}</Text>
|
||||
<Text style={styles.cellDept}>{formatDepartment(vip.department)}</Text>
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colOrg]}>
|
||||
{vip.organization ? (
|
||||
<Text style={styles.cellText}>{vip.organization}</Text>
|
||||
) : (
|
||||
<Text style={styles.cellNoData}>-</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colContact]}>
|
||||
{vip.phone && <Text style={styles.cellText}>{vip.phone}</Text>}
|
||||
{vip.email && <Text style={styles.cellSmall}>{vip.email}</Text>}
|
||||
{!vip.phone && !vip.email && <Text style={styles.cellNoData}>No contact info</Text>}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colEmergency]}>
|
||||
{vip.emergencyContactName ? (
|
||||
<>
|
||||
<Text style={styles.cellText}>{vip.emergencyContactName}</Text>
|
||||
{vip.emergencyContactPhone && (
|
||||
<Text style={styles.cellSmall}>{vip.emergencyContactPhone}</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.cellNoData}>Not provided</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.tableCell, styles.colParty]}>
|
||||
<Text style={styles.cellCenter}>{vip.partySize}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size={config.pageSize} style={styles.page}>
|
||||
{/* Watermarks */}
|
||||
{config.showDraftWatermark && (
|
||||
<View style={styles.watermark} fixed>
|
||||
<Text>DRAFT</Text>
|
||||
</View>
|
||||
)}
|
||||
{config.showConfidentialWatermark && (
|
||||
<View style={styles.watermark} fixed>
|
||||
<Text>CONFIDENTIAL</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
{config.logoUrl && (
|
||||
<View style={styles.logoContainer}>
|
||||
<Image src={config.logoUrl} style={styles.logo} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.orgName}>{config.organizationName}</Text>
|
||||
<Text style={styles.title}>Accountability Roster</Text>
|
||||
<Text style={styles.subtitle}>Emergency Preparedness & Personnel Tracking</Text>
|
||||
|
||||
{config.headerMessage && (
|
||||
<Text style={styles.customMessage}>{config.headerMessage}</Text>
|
||||
)}
|
||||
|
||||
{(config.showTimestamp || config.showAppUrl) && (
|
||||
<View style={styles.timestampBar}>
|
||||
{config.showTimestamp && (
|
||||
<Text style={styles.timestamp}>Generated: {generatedAt}</Text>
|
||||
)}
|
||||
{config.showAppUrl && (
|
||||
<Text style={styles.timestamp}>
|
||||
Latest version: {typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<View style={styles.summaryRow}>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{totalPeople}</Text>
|
||||
<Text style={styles.summaryLabel}>Total People</Text>
|
||||
</View>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{activeCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Active VIPs</Text>
|
||||
</View>
|
||||
<View style={styles.summaryCard}>
|
||||
<Text style={styles.summaryValue}>{rosterCount}</Text>
|
||||
<Text style={styles.summaryLabel}>Roster Only</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active VIPs Table */}
|
||||
{activeVips.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Active VIPs ({activeVips.length} entries, {activeCount} people)
|
||||
</Text>
|
||||
<View style={styles.table}>
|
||||
{renderTableHeader()}
|
||||
{activeVips.map((vip, i) => renderVipRow(vip, i, false))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Roster Only Table */}
|
||||
{rosterOnlyVips.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Roster Only ({rosterOnlyVips.length} entries, {rosterCount} people)
|
||||
</Text>
|
||||
<View style={styles.table}>
|
||||
{renderTableHeader()}
|
||||
{rosterOnlyVips.map((vip, i) => renderVipRow(vip, i, true))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{vips.length === 0 && (
|
||||
<Text style={styles.emptyState}>No personnel records found.</Text>
|
||||
)}
|
||||
|
||||
{/* Custom Footer Message */}
|
||||
{config.footerMessage && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.customMessage}>{config.footerMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer} fixed>
|
||||
<View style={styles.footerContent}>
|
||||
<View style={styles.footerLeft}>
|
||||
<Text style={styles.footerTitle}>{config.contactLabel}</Text>
|
||||
<Text style={styles.footerContact}>{config.contactEmail}</Text>
|
||||
<Text style={styles.footerContact}>{config.contactPhone}</Text>
|
||||
{config.secondaryContactName && (
|
||||
<Text style={styles.footerContact}>
|
||||
{config.secondaryContactName}
|
||||
{config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.footerRight}>
|
||||
<Text
|
||||
style={styles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`Page ${pageNumber} of ${totalPages}`
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/ConfirmModal.tsx
Normal file
90
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'destructive' | 'warning' | 'default';
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'destructive',
|
||||
}: ConfirmModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getConfirmButtonStyles = () => {
|
||||
switch (variant) {
|
||||
case 'destructive':
|
||||
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||
case 'warning':
|
||||
return 'bg-yellow-600 hover:bg-yellow-700 text-white';
|
||||
case 'default':
|
||||
return 'bg-primary hover:bg-primary/90 text-white';
|
||||
default:
|
||||
return 'bg-red-600 hover:bg-red-700 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-xl w-full max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with icon */}
|
||||
<div className="flex items-start gap-4 p-6 pb-4">
|
||||
<div className={`flex-shrink-0 ${
|
||||
variant === 'destructive' ? 'text-red-600' :
|
||||
variant === 'warning' ? 'text-yellow-600' :
|
||||
'text-primary'
|
||||
}`}>
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 p-6 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-card text-foreground py-2.5 px-4 rounded-md hover:bg-accent font-medium border border-input transition-colors"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onCancel();
|
||||
}}
|
||||
className={`flex-1 py-2.5 px-4 rounded-md font-medium transition-colors ${getConfirmButtonStyles()}`}
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
?.filter(vip => formData.vipIds.includes(vip.id))
|
||||
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
|
||||
.join(', ') || 'None selected';
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
36
frontend/src/components/SortableHeader.tsx
Normal file
36
frontend/src/components/SortableHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
interface SortableHeaderProps<T extends string> {
|
||||
column: T;
|
||||
label: string;
|
||||
currentSort: {
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
onSort: (key: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SortableHeader<T extends string>({ column, label, currentSort, onSort, className = '' }: SortableHeaderProps<T>) {
|
||||
const isActive = currentSort.key === column;
|
||||
|
||||
return (
|
||||
<th
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors select-none ${className}`}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentSort.direction === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4 text-primary" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { 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>
|
||||
|
||||
|
||||
71
frontend/src/contexts/TimezoneContext.tsx
Normal file
71
frontend/src/contexts/TimezoneContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface TimezoneContextValue {
|
||||
timezone: string;
|
||||
isLoading: boolean;
|
||||
setTimezone: (tz: string) => void;
|
||||
}
|
||||
|
||||
const TimezoneContext = createContext<TimezoneContextValue>({
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
isLoading: false,
|
||||
setTimezone: () => {},
|
||||
});
|
||||
|
||||
export function TimezoneProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ timezone: string }>({
|
||||
queryKey: ['settings', 'timezone'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/settings/timezone');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (timezone: string) => {
|
||||
const { data } = await api.patch('/settings/timezone', { timezone });
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['settings', 'timezone'], data);
|
||||
toast.success('Timezone updated');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update timezone');
|
||||
},
|
||||
});
|
||||
|
||||
const timezone = data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return (
|
||||
<TimezoneContext.Provider
|
||||
value={{
|
||||
timezone,
|
||||
isLoading,
|
||||
setTimezone: (tz: string) => mutation.mutate(tz),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TimezoneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app-wide timezone string
|
||||
*/
|
||||
export function useTimezone(): string {
|
||||
return useContext(TimezoneContext).timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full timezone context (timezone, isLoading, setTimezone)
|
||||
*/
|
||||
export function useTimezoneContext(): TimezoneContextValue {
|
||||
return useContext(TimezoneContext);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
28
frontend/src/hooks/useFormattedDate.ts
Normal file
28
frontend/src/hooks/useFormattedDate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTimezone } from '@/contexts/TimezoneContext';
|
||||
import { formatDate as fmtDate, formatDateTime as fmtDateTime, formatTime as fmtTime } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Returns format functions pre-bound with the app-wide timezone.
|
||||
* Use this in components instead of importing formatDate/DateTime/Time directly.
|
||||
*/
|
||||
export function useFormattedDate() {
|
||||
const timezone = useTimezone();
|
||||
|
||||
const formatDate = useCallback(
|
||||
(date: string | Date) => fmtDate(date, timezone),
|
||||
[timezone],
|
||||
);
|
||||
|
||||
const formatDateTime = useCallback(
|
||||
(date: string | Date) => fmtDateTime(date, timezone),
|
||||
[timezone],
|
||||
);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(date: string | Date) => fmtTime(date, timezone),
|
||||
[timezone],
|
||||
);
|
||||
|
||||
return { formatDate, formatDateTime, formatTime, timezone };
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import type {
|
||||
GpsSettings,
|
||||
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;
|
||||
|
||||
55
frontend/src/hooks/useListPage.ts
Normal file
55
frontend/src/hooks/useListPage.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
interface UseListPageOptions<T extends string> {
|
||||
defaultSortKey: T;
|
||||
defaultSortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export function useListPage<T extends string>(options: UseListPageOptions<T>) {
|
||||
const { defaultSortKey, defaultSortDirection = 'asc' } = options;
|
||||
|
||||
// Search state
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
// Sort state
|
||||
const [sortKey, setSortKey] = useState<T>(defaultSortKey);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection);
|
||||
|
||||
// Generic filter state (key-value pairs)
|
||||
const [filters, setFiltersState] = useState<Record<string, any>>({});
|
||||
|
||||
const handleSort = (key: T) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const setFilter = (key: string, value: any) => {
|
||||
setFiltersState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setFiltersState({});
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 })) || []
|
||||
);
|
||||
},
|
||||
|
||||
41
frontend/src/lib/enum-labels.ts
Normal file
41
frontend/src/lib/enum-labels.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Enum Display Labels
|
||||
* Centralized mapping of enum values to human-readable labels
|
||||
*/
|
||||
|
||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
OFFICE_OF_DEVELOPMENT: 'Office of Development',
|
||||
ADMIN: 'Admin',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
|
||||
export const ARRIVAL_MODE_LABELS: Record<string, string> = {
|
||||
FLIGHT: 'Flight',
|
||||
SELF_DRIVING: 'Self Driving',
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
TRANSPORT: 'Transport',
|
||||
MEETING: 'Meeting',
|
||||
EVENT: 'Event',
|
||||
MEAL: 'Meal',
|
||||
ACCOMMODATION: 'Accommodation',
|
||||
};
|
||||
|
||||
export const EVENT_STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Scheduled',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get a label for any enum value
|
||||
* Falls back to the value itself if no mapping is found
|
||||
*/
|
||||
export function getEnumLabel(
|
||||
value: string,
|
||||
labels: Record<string, string>
|
||||
): string {
|
||||
return labels[value] || value;
|
||||
}
|
||||
118
frontend/src/lib/journeyUtils.ts
Normal file
118
frontend/src/lib/journeyUtils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Flight, Journey, Layover, LayoverRiskLevel } from '@/types';
|
||||
|
||||
function getEffectiveArrival(flight: Flight): Date | null {
|
||||
const t = flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||
return t ? new Date(t) : null;
|
||||
}
|
||||
|
||||
function getEffectiveDeparture(flight: Flight): Date | null {
|
||||
const t = flight.actualDeparture || flight.estimatedDeparture || flight.scheduledDeparture;
|
||||
return t ? new Date(t) : null;
|
||||
}
|
||||
|
||||
function computeLayoverRisk(effectiveMinutes: number, scheduledMinutes: number): LayoverRiskLevel {
|
||||
if (effectiveMinutes < 0) return 'missed';
|
||||
if (effectiveMinutes < 30) return 'critical';
|
||||
if (effectiveMinutes < 60) return 'warning';
|
||||
if (scheduledMinutes > 0) return 'ok';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function computeLayover(arriving: Flight, departing: Flight, index: number): Layover {
|
||||
const scheduledArr = arriving.scheduledArrival ? new Date(arriving.scheduledArrival) : null;
|
||||
const scheduledDep = departing.scheduledDeparture ? new Date(departing.scheduledDeparture) : null;
|
||||
const effectiveArr = getEffectiveArrival(arriving);
|
||||
const effectiveDep = getEffectiveDeparture(departing);
|
||||
|
||||
const scheduledMinutes = scheduledArr && scheduledDep
|
||||
? (scheduledDep.getTime() - scheduledArr.getTime()) / 60000
|
||||
: 0;
|
||||
|
||||
const effectiveMinutes = effectiveArr && effectiveDep
|
||||
? (effectiveDep.getTime() - effectiveArr.getTime()) / 60000
|
||||
: scheduledMinutes;
|
||||
|
||||
return {
|
||||
airport: arriving.arrivalAirport,
|
||||
afterSegmentIndex: index,
|
||||
scheduledMinutes: Math.round(scheduledMinutes),
|
||||
effectiveMinutes: Math.round(effectiveMinutes),
|
||||
risk: computeLayoverRisk(effectiveMinutes, scheduledMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
function computeEffectiveStatus(flights: Flight[]): { effectiveStatus: string; currentSegmentIndex: number } {
|
||||
// Critical statuses on any segment take priority
|
||||
for (let i = 0; i < flights.length; i++) {
|
||||
const s = flights[i].status?.toLowerCase();
|
||||
if (s === 'cancelled' || s === 'diverted' || s === 'incident') {
|
||||
return { effectiveStatus: s, currentSegmentIndex: i };
|
||||
}
|
||||
}
|
||||
|
||||
// Find first non-terminal segment (the "active" one)
|
||||
for (let i = 0; i < flights.length; i++) {
|
||||
const s = flights[i].status?.toLowerCase();
|
||||
const isTerminal = s === 'landed' || !!flights[i].actualArrival;
|
||||
if (!isTerminal) {
|
||||
return { effectiveStatus: s || 'scheduled', currentSegmentIndex: i };
|
||||
}
|
||||
}
|
||||
|
||||
// All segments landed
|
||||
const last = flights.length - 1;
|
||||
return { effectiveStatus: flights[last].status?.toLowerCase() || 'landed', currentSegmentIndex: last };
|
||||
}
|
||||
|
||||
export function groupFlightsIntoJourneys(flights: Flight[]): Journey[] {
|
||||
const byVip = new Map<string, Flight[]>();
|
||||
for (const flight of flights) {
|
||||
const group = byVip.get(flight.vipId) || [];
|
||||
group.push(flight);
|
||||
byVip.set(flight.vipId, group);
|
||||
}
|
||||
|
||||
const journeys: Journey[] = [];
|
||||
|
||||
for (const [vipId, vipFlights] of byVip) {
|
||||
// Sort chronologically by departure time, then segment as tiebreaker
|
||||
const sorted = [...vipFlights].sort((a, b) => {
|
||||
const depA = a.scheduledDeparture || a.flightDate;
|
||||
const depB = b.scheduledDeparture || b.flightDate;
|
||||
const timeDiff = new Date(depA).getTime() - new Date(depB).getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.segment - b.segment;
|
||||
});
|
||||
|
||||
const layovers: Layover[] = [];
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
layovers.push(computeLayover(sorted[i], sorted[i + 1], i));
|
||||
}
|
||||
|
||||
const { effectiveStatus, currentSegmentIndex } = computeEffectiveStatus(sorted);
|
||||
|
||||
journeys.push({
|
||||
vipId,
|
||||
vip: sorted[0]?.vip,
|
||||
flights: sorted,
|
||||
layovers,
|
||||
effectiveStatus,
|
||||
currentSegmentIndex,
|
||||
hasLayoverRisk: layovers.some(l => l.risk === 'warning' || l.risk === 'critical' || l.risk === 'missed'),
|
||||
origin: sorted[0]?.departureAirport,
|
||||
destination: sorted[sorted.length - 1]?.arrivalAirport,
|
||||
isMultiSegment: sorted.length > 1,
|
||||
});
|
||||
}
|
||||
|
||||
return journeys;
|
||||
}
|
||||
|
||||
export function formatLayoverDuration(minutes: number): string {
|
||||
if (minutes < 0) return `${Math.abs(minutes)}min late`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}min`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}min`;
|
||||
}
|
||||
93
frontend/src/lib/query-keys.ts
Normal file
93
frontend/src/lib/query-keys.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Query key factory constants for TanStack Query
|
||||
*
|
||||
* This file provides typed, centralized query key management for all entities.
|
||||
* Using factory functions ensures consistent keys across the application.
|
||||
*
|
||||
* @see https://tkdodo.eu/blog/effective-react-query-keys
|
||||
*/
|
||||
|
||||
export const queryKeys = {
|
||||
// VIPs
|
||||
vips: {
|
||||
all: ['vips'] as const,
|
||||
detail: (id: string) => ['vip', id] as const,
|
||||
forSchedule: (vipIds: string) => ['vips-for-schedule', vipIds] as const,
|
||||
},
|
||||
|
||||
// Drivers
|
||||
drivers: {
|
||||
all: ['drivers'] as const,
|
||||
myProfile: ['my-driver-profile'] as const,
|
||||
schedule: (driverId: string, date: string) => ['driver-schedule', driverId, date] as const,
|
||||
},
|
||||
|
||||
// Events/Schedule
|
||||
events: {
|
||||
all: ['events'] as const,
|
||||
},
|
||||
|
||||
// Vehicles
|
||||
vehicles: {
|
||||
all: ['vehicles'] as const,
|
||||
},
|
||||
|
||||
// Flights
|
||||
flights: {
|
||||
all: ['flights'] as const,
|
||||
budget: ['flights', 'budget'] as const,
|
||||
},
|
||||
|
||||
// Users
|
||||
users: {
|
||||
all: ['users'] as const,
|
||||
},
|
||||
|
||||
// GPS/Location Tracking
|
||||
gps: {
|
||||
status: ['gps', 'status'] as const,
|
||||
settings: ['gps', 'settings'] as const,
|
||||
devices: ['gps', 'devices'] as const,
|
||||
deviceQr: (driverId: string) => ['gps', 'devices', driverId, 'qr'] as const,
|
||||
locations: {
|
||||
all: ['gps', 'locations'] as const,
|
||||
detail: (driverId: string) => ['gps', 'locations', driverId] as const,
|
||||
},
|
||||
stats: (driverId: string, from?: string, to?: string) =>
|
||||
['gps', 'stats', driverId, from, to] as const,
|
||||
me: {
|
||||
status: ['gps', 'me'] as const,
|
||||
stats: (from?: string, to?: string) => ['gps', 'me', 'stats', from, to] as const,
|
||||
location: ['gps', 'me', 'location'] as const,
|
||||
},
|
||||
traccar: {
|
||||
status: ['gps', 'traccar', 'status'] as const,
|
||||
adminUrl: ['gps', 'traccar', 'admin-url'] as const,
|
||||
},
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
pdf: ['settings', 'pdf'] as const,
|
||||
timezone: ['settings', 'timezone'] as const,
|
||||
},
|
||||
|
||||
// Signal Messages
|
||||
signal: {
|
||||
messages: (driverId: string) => ['signal-messages', driverId] as const,
|
||||
unreadCounts: ['signal-unread-counts'] as const,
|
||||
driverResponses: (eventIds: string) => ['signal-driver-responses', eventIds] as const,
|
||||
status: ['signal-status'] as const,
|
||||
messageStats: ['signal-message-stats'] as const,
|
||||
},
|
||||
|
||||
// Admin
|
||||
admin: {
|
||||
stats: ['admin-stats'] as const,
|
||||
},
|
||||
|
||||
// Features
|
||||
features: {
|
||||
all: ['features'] as const,
|
||||
},
|
||||
} as const;
|
||||
@@ -5,16 +5,17 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 © Esri — 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 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>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||
</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>
|
||||
|
||||
<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"
|
||||
>
|
||||
Sign In with Auth0
|
||||
</button>
|
||||
{/* Sign in card */}
|
||||
<div className="w-full">
|
||||
<button
|
||||
onClick={() => loginWithRedirect()}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
<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"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?')) {
|
||||
setProcessingUser(userId);
|
||||
approveMutation.mutate(userId);
|
||||
}
|
||||
const handleApprove = (userId: string, userName: string) => {
|
||||
setConfirmState({
|
||||
action: 'approve',
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeny = (userId: string) => {
|
||||
if (confirm('Delete this user? This action cannot be undone.')) {
|
||||
setProcessingUser(userId);
|
||||
deleteUserMutation.mutate(userId);
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user