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
|
// Location history
|
||||||
locationHistory GpsLocationHistory[]
|
locationHistory GpsLocationHistory[]
|
||||||
|
trips GpsTrip[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -420,7 +421,7 @@ model GpsLocationHistory {
|
|||||||
latitude Float
|
latitude Float
|
||||||
longitude Float
|
longitude Float
|
||||||
altitude Float?
|
altitude Float?
|
||||||
speed Float? // km/h
|
speed Float? // mph (converted from knots during sync)
|
||||||
course Float? // Bearing in degrees
|
course Float? // Bearing in degrees
|
||||||
accuracy Float? // Meters
|
accuracy Float? // Meters
|
||||||
battery Float? // Battery percentage (0-100)
|
battery Float? // Battery percentage (0-100)
|
||||||
@@ -430,10 +431,49 @@ model GpsLocationHistory {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("gps_location_history")
|
@@map("gps_location_history")
|
||||||
@@index([deviceId, timestamp])
|
@@unique([deviceId, timestamp]) // Prevent duplicate position records
|
||||||
@@index([timestamp]) // For cleanup job
|
@@index([timestamp]) // For cleanup job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TripStatus {
|
||||||
|
ACTIVE // Currently in progress
|
||||||
|
COMPLETED // Finished, OSRM route computed
|
||||||
|
PROCESSING // OSRM computation in progress
|
||||||
|
FAILED // OSRM computation failed
|
||||||
|
}
|
||||||
|
|
||||||
|
model GpsTrip {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
deviceId String
|
||||||
|
device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
status TripStatus @default(ACTIVE)
|
||||||
|
|
||||||
|
startTime DateTime
|
||||||
|
endTime DateTime?
|
||||||
|
startLatitude Float
|
||||||
|
startLongitude Float
|
||||||
|
endLatitude Float?
|
||||||
|
endLongitude Float?
|
||||||
|
|
||||||
|
// Pre-computed stats (filled on completion)
|
||||||
|
distanceMiles Float?
|
||||||
|
durationSeconds Int?
|
||||||
|
topSpeedMph Float?
|
||||||
|
averageSpeedMph Float?
|
||||||
|
pointCount Int @default(0)
|
||||||
|
|
||||||
|
// Pre-computed OSRM route (stored as JSON for instant display)
|
||||||
|
matchedRoute Json? // { coordinates: [lat,lng][], distance, duration, confidence }
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("gps_trips")
|
||||||
|
@@index([deviceId, startTime])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
model GpsSettings {
|
model GpsSettings {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export class AuthService {
|
|||||||
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
||||||
const picture = payload[`${namespace}/picture`] || payload.picture;
|
const picture = payload[`${namespace}/picture`] || payload.picture;
|
||||||
|
|
||||||
// Check if user exists (exclude soft-deleted users)
|
// Check if user exists (soft-deleted users automatically excluded by middleware)
|
||||||
let user = await this.prisma.user.findFirst({
|
let user = await this.prisma.user.findFirst({
|
||||||
where: { auth0Id, deletedAt: null },
|
where: { auth0Id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export class AuthService {
|
|||||||
// where two simultaneous registrations both become admin
|
// where two simultaneous registrations both become admin
|
||||||
user = await this.prisma.$transaction(async (tx) => {
|
user = await this.prisma.$transaction(async (tx) => {
|
||||||
const approvedUserCount = await tx.user.count({
|
const approvedUserCount = await tx.user.count({
|
||||||
where: { isApproved: true, deletedAt: null },
|
where: { isApproved: true },
|
||||||
});
|
});
|
||||||
const isFirstUser = approvedUserCount === 0;
|
const isFirstUser = approvedUserCount === 0;
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current user profile (excludes soft-deleted users)
|
* Get current user profile (soft-deleted users automatically excluded by middleware)
|
||||||
*/
|
*/
|
||||||
async getCurrentUser(auth0Id: string) {
|
async getCurrentUser(auth0Id: string) {
|
||||||
return this.prisma.user.findFirst({
|
return this.prisma.user.findFirst({
|
||||||
where: { auth0Id, deletedAt: null },
|
where: { auth0Id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
1
backend/src/common/pipes/index.ts
Normal file
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 { Module } from '@nestjs/common';
|
||||||
import { CopilotController } from './copilot.controller';
|
import { CopilotController } from './copilot.controller';
|
||||||
import { CopilotService } from './copilot.service';
|
import { CopilotService } from './copilot.service';
|
||||||
|
import { CopilotVipService } from './copilot-vip.service';
|
||||||
|
import { CopilotScheduleService } from './copilot-schedule.service';
|
||||||
|
import { CopilotFleetService } from './copilot-fleet.service';
|
||||||
|
import { CopilotReportsService } from './copilot-reports.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { SignalModule } from '../signal/signal.module';
|
import { SignalModule } from '../signal/signal.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
@@ -8,6 +12,12 @@ import { DriversModule } from '../drivers/drivers.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, SignalModule, DriversModule],
|
imports: [PrismaModule, SignalModule, DriversModule],
|
||||||
controllers: [CopilotController],
|
controllers: [CopilotController],
|
||||||
providers: [CopilotService],
|
providers: [
|
||||||
|
CopilotService,
|
||||||
|
CopilotVipService,
|
||||||
|
CopilotScheduleService,
|
||||||
|
CopilotFleetService,
|
||||||
|
CopilotReportsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CopilotModule {}
|
export class CopilotModule {}
|
||||||
|
|||||||
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,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
@@ -16,8 +17,12 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { CurrentDriver } from './decorators';
|
||||||
|
import { ResolveDriverInterceptor } from './interceptors';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -41,11 +46,8 @@ export class DriversController {
|
|||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
async getMyDriverProfile(@CurrentUser() user: any) {
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
getMyDriverProfile(@CurrentDriver() driver: any) {
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
return driver;
|
return driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,22 +57,19 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Get('me/schedule/ics')
|
@Get('me/schedule/ics')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async getMyScheduleICS(
|
async getMyScheduleICS(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Query('date') dateStr?: string,
|
@Query('date') dateStr?: string,
|
||||||
@Query('fullSchedule') fullScheduleStr?: string,
|
@Query('fullSchedule') fullScheduleStr?: string,
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = dateStr ? new Date(dateStr) : new Date();
|
const date = dateStr ? new Date(dateStr) : new Date();
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
const fullSchedule = fullScheduleStr !== 'false';
|
const fullSchedule = fullScheduleStr !== 'false';
|
||||||
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
? `full-schedule-${toDateString(new Date())}.ics`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
: `schedule-${toDateString(date)}.ics`;
|
||||||
return { ics: icsContent, filename };
|
return { ics: icsContent, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,22 +79,19 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Get('me/schedule/pdf')
|
@Get('me/schedule/pdf')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async getMySchedulePDF(
|
async getMySchedulePDF(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Query('date') dateStr?: string,
|
@Query('date') dateStr?: string,
|
||||||
@Query('fullSchedule') fullScheduleStr?: string,
|
@Query('fullSchedule') fullScheduleStr?: string,
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = dateStr ? new Date(dateStr) : new Date();
|
const date = dateStr ? new Date(dateStr) : new Date();
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
const fullSchedule = fullScheduleStr !== 'false';
|
const fullSchedule = fullScheduleStr !== 'false';
|
||||||
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
? `full-schedule-${toDateString(new Date())}.pdf`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
: `schedule-${toDateString(date)}.pdf`;
|
||||||
return { pdf: pdfBuffer.toString('base64'), filename };
|
return { pdf: pdfBuffer.toString('base64'), filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +101,11 @@ export class DriversController {
|
|||||||
*/
|
*/
|
||||||
@Post('me/send-schedule')
|
@Post('me/send-schedule')
|
||||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
async sendMySchedule(
|
async sendMySchedule(
|
||||||
@CurrentUser() user: any,
|
@CurrentDriver() driver: any,
|
||||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
||||||
) {
|
) {
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
const date = body.date ? new Date(body.date) : new Date();
|
const date = body.date ? new Date(body.date) : new Date();
|
||||||
const format = body.format || 'both';
|
const format = body.format || 'both';
|
||||||
// Default to full schedule (true) unless explicitly set to false
|
// Default to full schedule (true) unless explicitly set to false
|
||||||
@@ -122,11 +115,8 @@ export class DriversController {
|
|||||||
|
|
||||||
@Patch('me')
|
@Patch('me')
|
||||||
@Roles(Role.DRIVER)
|
@Roles(Role.DRIVER)
|
||||||
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
|
@UseInterceptors(ResolveDriverInterceptor)
|
||||||
const driver = await this.driversService.findByUserId(user.id);
|
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
|
||||||
if (!driver) {
|
|
||||||
throw new NotFoundException('Driver profile not found for current user');
|
|
||||||
}
|
|
||||||
return this.driversService.update(driver.id, updateDriverDto);
|
return this.driversService.update(driver.id, updateDriverDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +209,9 @@ export class DriversController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.driversService.remove(id, hard, user?.role);
|
||||||
return this.driversService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriversService {
|
export class DriversService {
|
||||||
private readonly logger = new Logger(DriversService.name);
|
private readonly logger = new Logger(DriversService.name);
|
||||||
|
|
||||||
|
private readonly driverInclude = {
|
||||||
|
user: true,
|
||||||
|
events: {
|
||||||
|
include: { vehicle: true, driver: true },
|
||||||
|
orderBy: { startTime: 'asc' as const },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createDriverDto: CreateDriverDto) {
|
async create(createDriverDto: CreateDriverDto) {
|
||||||
@@ -19,30 +28,15 @@ export class DriversService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.driver.findMany({
|
return this.prisma.driver.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.driverInclude,
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.driverInclude,
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -54,15 +48,8 @@ export class DriversService {
|
|||||||
|
|
||||||
async findByUserId(userId: string) {
|
async findByUserId(userId: string) {
|
||||||
return this.prisma.driver.findFirst({
|
return this.prisma.driver.findFirst({
|
||||||
where: { userId, deletedAt: null },
|
where: { userId },
|
||||||
include: {
|
include: this.driverInclude,
|
||||||
user: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { vehicle: true, driver: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,23 +66,19 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const driver = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting driver: ${driver.name}`);
|
this.prisma.driver.update({
|
||||||
return this.prisma.driver.delete({
|
where: { id },
|
||||||
where: { id: driver.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting driver: ${driver.name}`);
|
|
||||||
return this.prisma.driver.update({
|
|
||||||
where: { id: driver.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Driver',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
backend/src/drivers/interceptors/index.ts
Normal file
1
backend/src/drivers/interceptors/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './resolve-driver.interceptor';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
ExecutionContext,
|
||||||
|
CallHandler,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { DriversService } from '../drivers.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interceptor that resolves the current driver from the authenticated user
|
||||||
|
* and attaches it to the request object for /me routes.
|
||||||
|
* This prevents multiple calls to findByUserId() in each route handler.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ResolveDriverInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly driversService: DriversService) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve driver from user ID and attach to request
|
||||||
|
const driver = await this.driversService.findByUserId(user.id);
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
throw new NotFoundException('Driver profile not found for current user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach driver to request for use in route handlers
|
||||||
|
request.driver = driver;
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { SignalService } from '../signal/signal.service';
|
import { SignalService } from '../signal/signal.service';
|
||||||
import * as ics from 'ics';
|
import * as ics from 'ics';
|
||||||
import * as PDFDocument from 'pdfkit';
|
import * as PDFDocument from 'pdfkit';
|
||||||
|
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||||
|
|
||||||
interface ScheduleEventWithDetails {
|
interface ScheduleEventWithDetails {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,8 +37,7 @@ export class ScheduleExportService {
|
|||||||
driverId: string,
|
driverId: string,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<ScheduleEventWithDetails[]> {
|
): Promise<ScheduleEventWithDetails[]> {
|
||||||
const startOfDay = new Date(date);
|
const dayStart = startOfDay(date);
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
const endOfDay = new Date(date);
|
const endOfDay = new Date(date);
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
@@ -45,9 +45,8 @@ export class ScheduleExportService {
|
|||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
startTime: {
|
startTime: {
|
||||||
gte: startOfDay,
|
gte: dayStart,
|
||||||
lte: endOfDay,
|
lte: endOfDay,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -71,13 +70,11 @@ export class ScheduleExportService {
|
|||||||
async getDriverFullSchedule(
|
async getDriverFullSchedule(
|
||||||
driverId: string,
|
driverId: string,
|
||||||
): Promise<ScheduleEventWithDetails[]> {
|
): Promise<ScheduleEventWithDetails[]> {
|
||||||
const now = new Date();
|
const now = startOfDay(new Date()); // Start of today
|
||||||
now.setHours(0, 0, 0, 0); // Start of today
|
|
||||||
|
|
||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
endTime: {
|
endTime: {
|
||||||
gte: now, // Include events that haven't ended yet
|
gte: now, // Include events that haven't ended yet
|
||||||
},
|
},
|
||||||
@@ -134,7 +131,7 @@ export class ScheduleExportService {
|
|||||||
*/
|
*/
|
||||||
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
|
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -211,7 +208,7 @@ export class ScheduleExportService {
|
|||||||
*/
|
*/
|
||||||
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
|
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -358,7 +355,7 @@ export class ScheduleExportService {
|
|||||||
fullSchedule = false,
|
fullSchedule = false,
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -411,8 +408,8 @@ export class ScheduleExportService {
|
|||||||
const icsContent = await this.generateICS(driverId, date, fullSchedule);
|
const icsContent = await this.generateICS(driverId, date, fullSchedule);
|
||||||
const icsBase64 = Buffer.from(icsContent).toString('base64');
|
const icsBase64 = Buffer.from(icsContent).toString('base64');
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
? `full-schedule-${toDateString(new Date())}.ics`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
: `schedule-${toDateString(date)}.ics`;
|
||||||
|
|
||||||
await this.signalService.sendMessageWithAttachment(
|
await this.signalService.sendMessageWithAttachment(
|
||||||
fromNumber,
|
fromNumber,
|
||||||
@@ -435,8 +432,8 @@ export class ScheduleExportService {
|
|||||||
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
|
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
|
||||||
const pdfBase64 = pdfBuffer.toString('base64');
|
const pdfBase64 = pdfBuffer.toString('base64');
|
||||||
const filename = fullSchedule
|
const filename = fullSchedule
|
||||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
? `full-schedule-${toDateString(new Date())}.pdf`
|
||||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
: `schedule-${toDateString(date)}.pdf`;
|
||||||
|
|
||||||
await this.signalService.sendMessageWithAttachment(
|
await this.signalService.sendMessageWithAttachment(
|
||||||
fromNumber,
|
fromNumber,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||||
reminder20MinSent: false,
|
reminder20MinSent: false,
|
||||||
driverId: { not: null },
|
driverId: { not: null },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -110,7 +109,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||||
reminder5MinSent: false,
|
reminder5MinSent: false,
|
||||||
driverId: { not: null },
|
driverId: { not: null },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -218,7 +216,6 @@ Reply:
|
|||||||
where: {
|
where: {
|
||||||
status: EventStatus.SCHEDULED,
|
status: EventStatus.SCHEDULED,
|
||||||
startTime: { lte: now },
|
startTime: { lte: now },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -264,7 +261,6 @@ Reply:
|
|||||||
where: {
|
where: {
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
endTime: { lte: gracePeriodAgo },
|
endTime: { lte: gracePeriodAgo },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: true,
|
driver: true,
|
||||||
@@ -347,7 +343,6 @@ Reply with 1, 2, or 3`;
|
|||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: {
|
where: {
|
||||||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,7 +355,6 @@ Reply with 1, 2, or 3`;
|
|||||||
where: {
|
where: {
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: { vehicle: true },
|
include: { vehicle: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('events')
|
@Controller('events')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -59,10 +60,9 @@ export class EventsController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.eventsService.remove(id, hard, user?.role);
|
||||||
return this.eventsService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,23 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventsService {
|
export class EventsService {
|
||||||
private readonly logger = new Logger(EventsService.name);
|
private readonly logger = new Logger(EventsService.name);
|
||||||
|
|
||||||
|
private readonly eventInclude = {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
masterEvent: {
|
||||||
|
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
||||||
|
},
|
||||||
|
childEvents: {
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createEventDto: CreateEventDto) {
|
async create(createEventDto: CreateEventDto) {
|
||||||
@@ -22,7 +34,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: createEventDto.vipIds },
|
id: { in: createEventDto.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,17 +80,7 @@ export class EventsService {
|
|||||||
startTime: new Date(createEventDto.startTime),
|
startTime: new Date(createEventDto.startTime),
|
||||||
endTime: new Date(createEventDto.endTime),
|
endTime: new Date(createEventDto.endTime),
|
||||||
},
|
},
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(event);
|
return this.enrichEventWithVips(event);
|
||||||
@@ -87,38 +88,45 @@ export class EventsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
const events = await this.prisma.scheduleEvent.findMany({
|
const events = await this.prisma.scheduleEvent.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.eventInclude,
|
||||||
include: {
|
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { startTime: 'asc' },
|
orderBy: { startTime: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(events.map((event) => this.enrichEventWithVips(event)));
|
// Collect all unique VIP IDs from all events
|
||||||
|
const allVipIds = new Set<string>();
|
||||||
|
events.forEach((event) => {
|
||||||
|
event.vipIds?.forEach((vipId) => allVipIds.add(vipId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all VIPs in a single query (eliminates N+1)
|
||||||
|
const vipsMap = new Map();
|
||||||
|
if (allVipIds.size > 0) {
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: Array.from(allVipIds) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vips.forEach((vip) => vipsMap.set(vip.id, vip));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich each event with its VIPs from the map (no additional queries)
|
||||||
|
return events.map((event) => {
|
||||||
|
if (!event.vipIds || event.vipIds.length === 0) {
|
||||||
|
return { ...event, vips: [], vip: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vips = event.vipIds
|
||||||
|
.map((vipId) => vipsMap.get(vipId))
|
||||||
|
.filter((vip) => vip !== undefined);
|
||||||
|
|
||||||
|
return { ...event, vips, vip: vips[0] || null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const event = await this.prisma.scheduleEvent.findFirst({
|
const event = await this.prisma.scheduleEvent.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -136,7 +144,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: updateEventDto.vipIds },
|
id: { in: updateEventDto.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,17 +214,7 @@ export class EventsService {
|
|||||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(updatedEvent);
|
return this.enrichEventWithVips(updatedEvent);
|
||||||
@@ -233,40 +230,27 @@ export class EventsService {
|
|||||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: { status: updateEventStatusDto.status },
|
data: { status: updateEventStatusDto.status },
|
||||||
include: {
|
include: this.eventInclude,
|
||||||
driver: true,
|
|
||||||
vehicle: true,
|
|
||||||
masterEvent: {
|
|
||||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
|
||||||
},
|
|
||||||
childEvents: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
select: { id: true, title: true, type: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.enrichEventWithVips(updatedEvent);
|
return this.enrichEventWithVips(updatedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const event = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) =>
|
||||||
if (hardDelete) {
|
this.prisma.scheduleEvent.delete({ where: { id } }),
|
||||||
this.logger.log(`Hard deleting event: ${event.title}`);
|
performSoftDelete: (id) =>
|
||||||
return this.prisma.scheduleEvent.delete({
|
this.prisma.scheduleEvent.update({
|
||||||
where: { id: event.id },
|
where: { id },
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting event: ${event.title}`);
|
|
||||||
return this.prisma.scheduleEvent.update({
|
|
||||||
where: { id: event.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Event',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +259,7 @@ export class EventsService {
|
|||||||
*/
|
*/
|
||||||
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
|
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
|
||||||
const vehicle = await this.prisma.vehicle.findFirst({
|
const vehicle = await this.prisma.vehicle.findFirst({
|
||||||
where: { id: vehicleId, deletedAt: null },
|
where: { id: vehicleId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
@@ -283,7 +267,7 @@ export class EventsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: { id: { in: vipIds }, deletedAt: null },
|
where: { id: { in: vipIds } },
|
||||||
select: { partySize: true },
|
select: { partySize: true },
|
||||||
});
|
});
|
||||||
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
|
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
|
||||||
@@ -313,7 +297,6 @@ export class EventsService {
|
|||||||
return this.prisma.scheduleEvent.findMany({
|
return this.prisma.scheduleEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
driverId,
|
driverId,
|
||||||
deletedAt: null,
|
|
||||||
id: excludeEventId ? { not: excludeEventId } : undefined,
|
id: excludeEventId ? { not: excludeEventId } : undefined,
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
@@ -354,7 +337,6 @@ export class EventsService {
|
|||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: event.vipIds },
|
id: { in: event.vipIds },
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { Flight } from '@prisma/client';
|
import { Flight } from '@prisma/client';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
|
||||||
// Tracking phases - determines polling priority
|
// Tracking phases - determines polling priority
|
||||||
const PHASE = {
|
const PHASE = {
|
||||||
@@ -319,7 +320,7 @@ export class FlightTrackingService {
|
|||||||
|
|
||||||
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
|
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
|
||||||
const flightDate = flight.flightDate
|
const flightDate = flight.flightDate
|
||||||
? new Date(flight.flightDate).toISOString().split('T')[0]
|
? toDateString(new Date(flight.flightDate))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('flights')
|
@Controller('flights')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -88,9 +89,8 @@ export class FlightsController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.flightsService.remove(id, hard);
|
||||||
return this.flightsService.remove(id, isHardDelete);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { convertOptionalDates } from '../common/utils/date.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlightsService {
|
export class FlightsService {
|
||||||
@@ -24,17 +25,16 @@ export class FlightsService {
|
|||||||
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
|
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.prisma.flight.create({
|
const data = convertOptionalDates(
|
||||||
data: {
|
{
|
||||||
...createFlightDto,
|
...createFlightDto,
|
||||||
flightDate: new Date(createFlightDto.flightDate),
|
flightDate: new Date(createFlightDto.flightDate),
|
||||||
scheduledDeparture: createFlightDto.scheduledDeparture
|
|
||||||
? new Date(createFlightDto.scheduledDeparture)
|
|
||||||
: undefined,
|
|
||||||
scheduledArrival: createFlightDto.scheduledArrival
|
|
||||||
? new Date(createFlightDto.scheduledArrival)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
|
['scheduledDeparture', 'scheduledArrival'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.prisma.flight.create({
|
||||||
|
data,
|
||||||
include: { vip: true },
|
include: { vip: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,24 +71,13 @@ export class FlightsService {
|
|||||||
|
|
||||||
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
|
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
|
||||||
|
|
||||||
const updateData: any = { ...updateFlightDto };
|
const updateData = convertOptionalDates(updateFlightDto, [
|
||||||
const dto = updateFlightDto as any; // Type assertion to work around PartialType
|
'flightDate',
|
||||||
|
'scheduledDeparture',
|
||||||
if (dto.flightDate) {
|
'scheduledArrival',
|
||||||
updateData.flightDate = new Date(dto.flightDate);
|
'actualDeparture',
|
||||||
}
|
'actualArrival',
|
||||||
if (dto.scheduledDeparture) {
|
]);
|
||||||
updateData.scheduledDeparture = new Date(dto.scheduledDeparture);
|
|
||||||
}
|
|
||||||
if (dto.scheduledArrival) {
|
|
||||||
updateData.scheduledArrival = new Date(dto.scheduledArrival);
|
|
||||||
}
|
|
||||||
if (dto.actualDeparture) {
|
|
||||||
updateData.actualDeparture = new Date(dto.actualDeparture);
|
|
||||||
}
|
|
||||||
if (dto.actualArrival) {
|
|
||||||
updateData.actualArrival = new Date(dto.actualArrival);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.flight.update({
|
return this.prisma.flight.update({
|
||||||
where: { id: flight.id },
|
where: { id: flight.id },
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class DriverStatsDto {
|
|||||||
averageSpeedMph: number;
|
averageSpeedMph: number;
|
||||||
totalTrips: number;
|
totalTrips: number;
|
||||||
totalDrivingMinutes: number;
|
totalDrivingMinutes: number;
|
||||||
|
distanceMethod?: string; // 'osrm' or 'haversine'
|
||||||
};
|
};
|
||||||
recentLocations: LocationDataDto[];
|
recentLocations: LocationDataDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ export class GpsController {
|
|||||||
return this.gpsService.getEnrolledDevices();
|
return this.gpsService.getEnrolledDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code info for an enrolled device
|
||||||
|
*/
|
||||||
|
@Get('devices/:driverId/qr')
|
||||||
|
@Roles(Role.ADMINISTRATOR)
|
||||||
|
async getDeviceQr(@Param('driverId') driverId: string) {
|
||||||
|
return this.gpsService.getDeviceQrInfo(driverId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enroll a driver for GPS tracking
|
* Enroll a driver for GPS tracking
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +109,7 @@ export class GpsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (Admin map view)
|
* Get all active driver locations (used by CommandCenter)
|
||||||
*/
|
*/
|
||||||
@Get('locations')
|
@Get('locations')
|
||||||
@Roles(Role.ADMINISTRATOR)
|
@Roles(Role.ADMINISTRATOR)
|
||||||
@@ -108,34 +117,6 @@ export class GpsController {
|
|||||||
return this.gpsService.getActiveDriverLocations();
|
return this.gpsService.getActiveDriverLocations();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific driver's location
|
|
||||||
*/
|
|
||||||
@Get('locations/:driverId')
|
|
||||||
@Roles(Role.ADMINISTRATOR)
|
|
||||||
async getDriverLocation(@Param('driverId') driverId: string) {
|
|
||||||
const location = await this.gpsService.getDriverLocation(driverId);
|
|
||||||
if (!location) {
|
|
||||||
throw new NotFoundException('Driver not found or not enrolled for GPS tracking');
|
|
||||||
}
|
|
||||||
return location;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a driver's stats (Admin viewing any driver)
|
|
||||||
*/
|
|
||||||
@Get('stats/:driverId')
|
|
||||||
@Roles(Role.ADMINISTRATOR)
|
|
||||||
async getDriverStats(
|
|
||||||
@Param('driverId') driverId: string,
|
|
||||||
@Query('from') fromStr?: string,
|
|
||||||
@Query('to') toStr?: string,
|
|
||||||
) {
|
|
||||||
const from = fromStr ? new Date(fromStr) : undefined;
|
|
||||||
const to = toStr ? new Date(toStr) : undefined;
|
|
||||||
return this.gpsService.getDriverStats(driverId, from, to);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Traccar Admin Access
|
// Traccar Admin Access
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -196,14 +196,18 @@ export class GpsService implements OnModuleInit {
|
|||||||
const settings = await this.getSettings();
|
const settings = await this.getSettings();
|
||||||
|
|
||||||
// Build QR code URL for Traccar Client app
|
// Build QR code URL for Traccar Client app
|
||||||
// Format: https://server:5055?id=DEVICE_ID&interval=SECONDS
|
|
||||||
// The Traccar Client app parses this as: server URL (origin) + query params (id, interval, etc.)
|
|
||||||
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
||||||
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
||||||
const qrUrl = new URL(traccarPublicUrl);
|
const qrUrl = new URL(traccarPublicUrl);
|
||||||
qrUrl.port = String(devicePort);
|
qrUrl.port = String(devicePort);
|
||||||
qrUrl.searchParams.set('id', actualDeviceId);
|
qrUrl.searchParams.set('id', actualDeviceId);
|
||||||
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
||||||
|
qrUrl.searchParams.set('accuracy', 'highest');
|
||||||
|
qrUrl.searchParams.set('distance', '0');
|
||||||
|
qrUrl.searchParams.set('angle', '30');
|
||||||
|
qrUrl.searchParams.set('heartbeat', '300');
|
||||||
|
qrUrl.searchParams.set('stop_detection', 'false');
|
||||||
|
qrUrl.searchParams.set('buffer', 'true');
|
||||||
const qrCodeUrl = qrUrl.toString();
|
const qrCodeUrl = qrUrl.toString();
|
||||||
|
|
||||||
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
|
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
|
||||||
@@ -215,15 +219,21 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
|||||||
- iOS: https://apps.apple.com/app/traccar-client/id843156974
|
- iOS: https://apps.apple.com/app/traccar-client/id843156974
|
||||||
- Android: https://play.google.com/store/apps/details?id=org.traccar.client
|
- Android: https://play.google.com/store/apps/details?id=org.traccar.client
|
||||||
|
|
||||||
2. Open the app and configure:
|
2. Open the app and scan the QR code (or configure manually):
|
||||||
- Device identifier: ${actualDeviceId}
|
- Device identifier: ${actualDeviceId}
|
||||||
- Server URL: ${serverUrl}
|
- Server URL: ${serverUrl}
|
||||||
|
- Location accuracy: Highest
|
||||||
- Frequency: ${settings.updateIntervalSeconds} seconds
|
- Frequency: ${settings.updateIntervalSeconds} seconds
|
||||||
- Location accuracy: High
|
- Distance: 0
|
||||||
|
- Angle: 30
|
||||||
|
|
||||||
3. Tap "Service Status" to start tracking.
|
3. IMPORTANT iPhone Settings:
|
||||||
|
- Settings > Privacy > Location Services > Traccar Client > "Always"
|
||||||
|
- Settings > General > Background App Refresh > ON for Traccar Client
|
||||||
|
- Do NOT swipe the app away from the app switcher
|
||||||
|
- Low Power Mode should be OFF while driving
|
||||||
|
|
||||||
Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}:00 - ${settings.shiftEndHour}:00).
|
4. Tap "Service Status" to start tracking.
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
let signalMessageSent = false;
|
let signalMessageSent = false;
|
||||||
@@ -248,7 +258,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
|
deviceIdentifier: actualDeviceId,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
qrCodeUrl,
|
qrCodeUrl,
|
||||||
instructions,
|
instructions,
|
||||||
@@ -256,6 +266,50 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code info for an already-enrolled device
|
||||||
|
*/
|
||||||
|
async getDeviceQrInfo(driverId: string): Promise<{
|
||||||
|
driverName: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
serverUrl: string;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
}> {
|
||||||
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
|
where: { driverId },
|
||||||
|
include: { driver: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new NotFoundException('Driver is not enrolled for GPS tracking');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await this.getSettings();
|
||||||
|
const serverUrl = this.traccarClient.getDeviceServerUrl();
|
||||||
|
|
||||||
|
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
|
||||||
|
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
||||||
|
const qrUrl = new URL(traccarPublicUrl);
|
||||||
|
qrUrl.port = String(devicePort);
|
||||||
|
qrUrl.searchParams.set('id', device.deviceIdentifier);
|
||||||
|
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
||||||
|
qrUrl.searchParams.set('accuracy', 'highest');
|
||||||
|
qrUrl.searchParams.set('distance', '0');
|
||||||
|
qrUrl.searchParams.set('angle', '30');
|
||||||
|
qrUrl.searchParams.set('heartbeat', '300');
|
||||||
|
qrUrl.searchParams.set('stop_detection', 'false');
|
||||||
|
qrUrl.searchParams.set('buffer', 'true');
|
||||||
|
|
||||||
|
return {
|
||||||
|
driverName: device.driver.name,
|
||||||
|
deviceIdentifier: device.deviceIdentifier,
|
||||||
|
serverUrl,
|
||||||
|
qrCodeUrl: qrUrl.toString(),
|
||||||
|
updateIntervalSeconds: settings.updateIntervalSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unenroll a driver from GPS tracking
|
* Unenroll a driver from GPS tracking
|
||||||
*/
|
*/
|
||||||
@@ -331,15 +385,12 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (Admin only)
|
* Get all active driver locations (used by CommandCenter + GPS page)
|
||||||
*/
|
*/
|
||||||
async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
|
async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
|
||||||
const devices = await this.prisma.gpsDevice.findMany({
|
const devices = await this.prisma.gpsDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
driver: {
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
driver: {
|
driver: {
|
||||||
@@ -387,7 +438,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a specific driver's location
|
* Get a specific driver's location (used by driver self-service)
|
||||||
*/
|
*/
|
||||||
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
|
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
|
||||||
const device = await this.prisma.gpsDevice.findUnique({
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
@@ -437,7 +488,98 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get driver's own stats (for driver self-view)
|
* Calculate distance between two GPS coordinates using Haversine formula
|
||||||
|
* Returns distance in miles
|
||||||
|
*/
|
||||||
|
private calculateHaversineDistance(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number,
|
||||||
|
): number {
|
||||||
|
const R = 3958.8; // Earth's radius in miles
|
||||||
|
const dLat = this.toRadians(lat2 - lat1);
|
||||||
|
const dLon = this.toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(this.toRadians(lat1)) *
|
||||||
|
Math.cos(this.toRadians(lat2)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRadians(degrees: number): number {
|
||||||
|
return degrees * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total distance from position history
|
||||||
|
*/
|
||||||
|
private async calculateDistanceFromHistory(
|
||||||
|
deviceId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<number> {
|
||||||
|
const positions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
|
where: {
|
||||||
|
deviceId,
|
||||||
|
timestamp: {
|
||||||
|
gte: from,
|
||||||
|
lte: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
select: {
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
timestamp: true,
|
||||||
|
speed: true,
|
||||||
|
accuracy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (positions.length < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMiles = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < positions.length; i++) {
|
||||||
|
const prev = positions[i - 1];
|
||||||
|
const curr = positions[i];
|
||||||
|
|
||||||
|
const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime();
|
||||||
|
const timeDiffMinutes = timeDiffMs / 60000;
|
||||||
|
|
||||||
|
// Skip if gap is too large (more than 10 minutes)
|
||||||
|
if (timeDiffMinutes > 10) continue;
|
||||||
|
|
||||||
|
const distance = this.calculateHaversineDistance(
|
||||||
|
prev.latitude,
|
||||||
|
prev.longitude,
|
||||||
|
curr.latitude,
|
||||||
|
curr.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sanity check: skip unrealistic distances (> 100 mph equivalent)
|
||||||
|
const maxPossibleDistance = (timeDiffMinutes / 60) * 100;
|
||||||
|
if (distance > maxPossibleDistance) continue;
|
||||||
|
|
||||||
|
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
|
||||||
|
if (distance < 0.01) continue;
|
||||||
|
|
||||||
|
totalMiles += distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalMiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get driver stats (used by driver self-service via me/stats)
|
||||||
*/
|
*/
|
||||||
async getDriverStats(
|
async getDriverStats(
|
||||||
driverId: string,
|
driverId: string,
|
||||||
@@ -464,58 +606,54 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
const to = toDate || new Date();
|
const to = toDate || new Date();
|
||||||
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Get summary from Traccar
|
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
|
||||||
let totalMiles = 0;
|
|
||||||
|
// Get all positions for speed/time analysis
|
||||||
|
const allPositions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
|
where: {
|
||||||
|
deviceId: device.id,
|
||||||
|
timestamp: {
|
||||||
|
gte: from,
|
||||||
|
lte: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
let topSpeedMph = 0;
|
let topSpeedMph = 0;
|
||||||
let topSpeedTimestamp: Date | null = null;
|
let topSpeedTimestamp: Date | null = null;
|
||||||
let totalTrips = 0;
|
|
||||||
let totalDrivingMinutes = 0;
|
let totalDrivingMinutes = 0;
|
||||||
|
let currentTripStart: Date | null = null;
|
||||||
|
let totalTrips = 0;
|
||||||
|
|
||||||
try {
|
for (const pos of allPositions) {
|
||||||
const summary = await this.traccarClient.getSummaryReport(
|
const speedMph = pos.speed || 0;
|
||||||
device.traccarDeviceId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (summary.length > 0) {
|
if (speedMph > topSpeedMph) {
|
||||||
const report = summary[0];
|
topSpeedMph = speedMph;
|
||||||
// Distance is in meters, convert to miles
|
topSpeedTimestamp = pos.timestamp;
|
||||||
totalMiles = (report.distance || 0) / 1609.344;
|
|
||||||
topSpeedMph = this.traccarClient.knotsToMph(report.maxSpeed || 0);
|
|
||||||
// Engine hours in milliseconds, convert to minutes
|
|
||||||
totalDrivingMinutes = Math.round((report.engineHours || 0) / 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get trips for additional stats
|
if (speedMph > 5) {
|
||||||
const trips = await this.traccarClient.getTripReport(
|
if (!currentTripStart) {
|
||||||
device.traccarDeviceId,
|
currentTripStart = pos.timestamp;
|
||||||
from,
|
totalTrips++;
|
||||||
to,
|
}
|
||||||
);
|
} else if (currentTripStart) {
|
||||||
totalTrips = trips.length;
|
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
|
||||||
|
totalDrivingMinutes += tripDurationMs / 60000;
|
||||||
// Find top speed timestamp from positions
|
currentTripStart = null;
|
||||||
const positions = await this.traccarClient.getPositionHistory(
|
|
||||||
device.traccarDeviceId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
);
|
|
||||||
|
|
||||||
let maxSpeed = 0;
|
|
||||||
for (const pos of positions) {
|
|
||||||
const speedMph = this.traccarClient.knotsToMph(pos.speed || 0);
|
|
||||||
if (speedMph > maxSpeed) {
|
|
||||||
maxSpeed = speedMph;
|
|
||||||
topSpeedTimestamp = new Date(pos.deviceTime);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
topSpeedMph = maxSpeed;
|
|
||||||
} catch (error) {
|
// Close last trip if still driving
|
||||||
this.logger.warn(`Failed to fetch stats from Traccar: ${error}`);
|
if (currentTripStart && allPositions.length > 0) {
|
||||||
|
const lastPos = allPositions[allPositions.length - 1];
|
||||||
|
const tripDurationMs = lastPos.timestamp.getTime() - currentTripStart.getTime();
|
||||||
|
totalDrivingMinutes += tripDurationMs / 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recent locations from our database
|
// Get recent locations for display (last 100)
|
||||||
const recentLocations = await this.prisma.gpsLocationHistory.findMany({
|
const recentLocations = await this.prisma.gpsLocationHistory.findMany({
|
||||||
where: {
|
where: {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
@@ -528,6 +666,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const averageSpeedMph =
|
||||||
|
totalDrivingMinutes > 0
|
||||||
|
? totalMiles / (totalDrivingMinutes / 60)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
driverId,
|
driverId,
|
||||||
driverName: device.driver.name,
|
driverName: device.driver.name,
|
||||||
@@ -539,11 +682,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
totalMiles: Math.round(totalMiles * 10) / 10,
|
totalMiles: Math.round(totalMiles * 10) / 10,
|
||||||
topSpeedMph: Math.round(topSpeedMph),
|
topSpeedMph: Math.round(topSpeedMph),
|
||||||
topSpeedTimestamp,
|
topSpeedTimestamp,
|
||||||
averageSpeedMph: totalDrivingMinutes > 0
|
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||||
? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10
|
|
||||||
: 0,
|
|
||||||
totalTrips,
|
totalTrips,
|
||||||
totalDrivingMinutes,
|
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
||||||
},
|
},
|
||||||
recentLocations: recentLocations.map((loc) => ({
|
recentLocations: recentLocations.map((loc) => ({
|
||||||
latitude: loc.latitude,
|
latitude: loc.latitude,
|
||||||
@@ -562,7 +703,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
* Sync positions from Traccar to our database (for history/stats)
|
* Sync positions from Traccar to our database (for history/stats)
|
||||||
* Called periodically via cron job
|
* Called periodically via cron job
|
||||||
*/
|
*/
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||||
async syncPositions(): Promise<void> {
|
async syncPositions(): Promise<void> {
|
||||||
const devices = await this.prisma.gpsDevice.findMany({
|
const devices = await this.prisma.gpsDevice.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -570,41 +711,67 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (devices.length === 0) return;
|
if (devices.length === 0) {
|
||||||
|
this.logger.debug('[GPS Sync] No active devices to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const now = new Date();
|
||||||
const positions = await this.traccarClient.getAllPositions();
|
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
|
try {
|
||||||
if (!position) continue;
|
const since = device.lastActive
|
||||||
|
? new Date(device.lastActive.getTime() - 30000)
|
||||||
|
: new Date(now.getTime() - 120000);
|
||||||
|
|
||||||
// Update last active timestamp
|
const positions = await this.traccarClient.getPositionHistory(
|
||||||
|
device.traccarDeviceId,
|
||||||
|
since,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
|
||||||
|
|
||||||
|
if (positions.length === 0) continue;
|
||||||
|
|
||||||
|
const insertResult = await this.prisma.gpsLocationHistory.createMany({
|
||||||
|
data: positions.map((p) => ({
|
||||||
|
deviceId: device.id,
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
altitude: p.altitude || null,
|
||||||
|
speed: this.traccarClient.knotsToMph(p.speed || 0),
|
||||||
|
course: p.course || null,
|
||||||
|
accuracy: p.accuracy || null,
|
||||||
|
battery: p.attributes?.batteryLevel || null,
|
||||||
|
timestamp: new Date(p.deviceTime),
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = insertResult.count;
|
||||||
|
const skipped = positions.length - inserted;
|
||||||
|
this.logger.log(
|
||||||
|
`[GPS Sync] Device ${device.traccarDeviceId}: ` +
|
||||||
|
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestPosition = positions.reduce((latest, p) =>
|
||||||
|
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
|
||||||
|
);
|
||||||
await this.prisma.gpsDevice.update({
|
await this.prisma.gpsDevice.update({
|
||||||
where: { id: device.id },
|
where: { id: device.id },
|
||||||
data: { lastActive: new Date(position.deviceTime) },
|
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store in history
|
|
||||||
await this.prisma.gpsLocationHistory.create({
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
latitude: position.latitude,
|
|
||||||
longitude: position.longitude,
|
|
||||||
altitude: position.altitude || null,
|
|
||||||
speed: this.traccarClient.knotsToMph(position.speed || 0),
|
|
||||||
course: position.course || null,
|
|
||||||
accuracy: position.accuracy || null,
|
|
||||||
battery: position.attributes?.batteryLevel || null,
|
|
||||||
timestamp: new Date(position.deviceTime),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to sync positions: ${error}`);
|
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log('[GPS Sync] Sync completed');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up old location history (runs daily at 2 AM)
|
* Clean up old location history (runs daily at 2 AM)
|
||||||
*/
|
*/
|
||||||
@@ -629,11 +796,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
// Traccar User Sync (VIP Admin -> Traccar Admin)
|
// Traccar User Sync (VIP Admin -> Traccar Admin)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure password for Traccar user
|
|
||||||
*/
|
|
||||||
private generateTraccarPassword(userId: string): string {
|
private generateTraccarPassword(userId: string): string {
|
||||||
// Generate deterministic but secure password based on user ID + secret
|
|
||||||
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
|
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', secret)
|
.createHmac('sha256', secret)
|
||||||
@@ -642,11 +805,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
.substring(0, 24);
|
.substring(0, 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure token for Traccar auto-login
|
|
||||||
*/
|
|
||||||
private generateTraccarToken(userId: string): string {
|
private generateTraccarToken(userId: string): string {
|
||||||
// Generate deterministic token for auto-login
|
|
||||||
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
|
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
|
||||||
return crypto
|
return crypto
|
||||||
.createHmac('sha256', secret + '-token')
|
.createHmac('sha256', secret + '-token')
|
||||||
@@ -655,9 +814,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
.substring(0, 32);
|
.substring(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync a VIP user to Traccar
|
|
||||||
*/
|
|
||||||
async syncUserToTraccar(user: User): Promise<boolean> {
|
async syncUserToTraccar(user: User): Promise<boolean> {
|
||||||
if (!user.email) return false;
|
if (!user.email) return false;
|
||||||
|
|
||||||
@@ -671,7 +827,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
user.name || user.email,
|
user.name || user.email,
|
||||||
password,
|
password,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
token, // Include token for auto-login
|
token,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
|
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
|
||||||
@@ -682,15 +838,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync all VIP admins to Traccar
|
|
||||||
*/
|
|
||||||
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
|
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
|
||||||
const admins = await this.prisma.user.findMany({
|
const admins = await this.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: 'ADMINISTRATOR',
|
role: 'ADMINISTRATOR',
|
||||||
isApproved: true,
|
isApproved: true,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -707,9 +859,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return { synced, failed };
|
return { synced, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get auto-login URL for Traccar (for admin users)
|
|
||||||
*/
|
|
||||||
async getTraccarAutoLoginUrl(user: User): Promise<{
|
async getTraccarAutoLoginUrl(user: User): Promise<{
|
||||||
url: string;
|
url: string;
|
||||||
directAccess: boolean;
|
directAccess: boolean;
|
||||||
@@ -718,30 +867,22 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
throw new BadRequestException('Only administrators can access Traccar admin');
|
throw new BadRequestException('Only administrators can access Traccar admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user is synced to Traccar (this also sets up their token)
|
|
||||||
await this.syncUserToTraccar(user);
|
await this.syncUserToTraccar(user);
|
||||||
|
|
||||||
// Get the token for auto-login
|
|
||||||
const token = this.generateTraccarToken(user.id);
|
const token = this.generateTraccarToken(user.id);
|
||||||
const baseUrl = this.traccarClient.getTraccarUrl();
|
const baseUrl = this.traccarClient.getTraccarUrl();
|
||||||
|
|
||||||
// Return URL with token parameter for auto-login
|
|
||||||
// Traccar supports ?token=xxx for direct authentication
|
|
||||||
return {
|
return {
|
||||||
url: `${baseUrl}?token=${token}`,
|
url: `${baseUrl}?token=${token}`,
|
||||||
directAccess: true,
|
directAccess: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Traccar session cookie for a user (for proxy/iframe auth)
|
|
||||||
*/
|
|
||||||
async getTraccarSessionForUser(user: User): Promise<string | null> {
|
async getTraccarSessionForUser(user: User): Promise<string | null> {
|
||||||
if (user.role !== 'ADMINISTRATOR') {
|
if (user.role !== 'ADMINISTRATOR') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user is synced
|
|
||||||
await this.syncUserToTraccar(user);
|
await this.syncUserToTraccar(user);
|
||||||
|
|
||||||
const password = this.generateTraccarPassword(user.id);
|
const password = this.generateTraccarPassword(user.id);
|
||||||
@@ -750,9 +891,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return session?.cookie || null;
|
return session?.cookie || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Traccar needs initial setup
|
|
||||||
*/
|
|
||||||
async checkTraccarSetup(): Promise<{
|
async checkTraccarSetup(): Promise<{
|
||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
@@ -766,11 +904,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
return { needsSetup, isAvailable };
|
return { needsSetup, isAvailable };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform initial Traccar setup
|
|
||||||
*/
|
|
||||||
async performTraccarSetup(adminEmail: string): Promise<boolean> {
|
async performTraccarSetup(adminEmail: string): Promise<boolean> {
|
||||||
// Generate a secure password for the service account
|
|
||||||
const servicePassword = crypto.randomBytes(16).toString('hex');
|
const servicePassword = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const success = await this.traccarClient.performInitialSetup(
|
const success = await this.traccarClient.performInitialSetup(
|
||||||
@@ -779,7 +913,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Save the service account credentials to settings
|
|
||||||
await this.updateSettings({
|
await this.updateSettings({
|
||||||
traccarAdminUser: adminEmail,
|
traccarAdminUser: adminEmail,
|
||||||
traccarAdminPassword: servicePassword,
|
traccarAdminPassword: servicePassword,
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export class TraccarClientService implements OnModuleInit {
|
|||||||
deviceId: number,
|
deviceId: number,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<any[]> {
|
): Promise<TraccarTrip[]> {
|
||||||
const fromStr = from.toISOString();
|
const fromStr = from.toISOString();
|
||||||
const toStr = to.toISOString();
|
const toStr = to.toISOString();
|
||||||
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
||||||
@@ -567,3 +567,27 @@ export interface TraccarUser {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TraccarTrip {
|
||||||
|
deviceId: number;
|
||||||
|
deviceName: string;
|
||||||
|
distance: number; // meters
|
||||||
|
averageSpeed: number; // knots
|
||||||
|
maxSpeed: number; // knots
|
||||||
|
spentFuel: number;
|
||||||
|
startOdometer: number;
|
||||||
|
endOdometer: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startPositionId: number;
|
||||||
|
endPositionId: number;
|
||||||
|
startLat: number;
|
||||||
|
startLon: number;
|
||||||
|
endLat: number;
|
||||||
|
endLon: number;
|
||||||
|
startAddress: string | null;
|
||||||
|
endAddress: string | null;
|
||||||
|
duration: number; // milliseconds
|
||||||
|
driverUniqueId: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Models that have soft delete (deletedAt field)
|
||||||
|
const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
@@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
|||||||
super({
|
super({
|
||||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply soft-delete middleware
|
||||||
|
this.applySoftDeleteMiddleware();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
try {
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
this.logger.log('✅ Database connected successfully');
|
this.logger.log('✅ Database connected successfully');
|
||||||
|
this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', '));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('❌ Database connection failed', error);
|
this.logger.error('❌ Database connection failed', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Prisma middleware to automatically filter out soft-deleted records
|
||||||
|
*
|
||||||
|
* This middleware automatically adds `deletedAt: null` to where clauses for models
|
||||||
|
* that have a deletedAt field, preventing soft-deleted records from being returned.
|
||||||
|
*
|
||||||
|
* Escape hatches:
|
||||||
|
* - Pass `{ deletedAt: { not: null } }` to query ONLY deleted records
|
||||||
|
* - Pass `{ deletedAt: undefined }` or any explicit deletedAt filter to bypass middleware
|
||||||
|
* - Hard delete operations (delete, deleteMany) are not affected
|
||||||
|
*/
|
||||||
|
private applySoftDeleteMiddleware() {
|
||||||
|
this.$use(async (params, next) => {
|
||||||
|
// Only apply to models with soft delete
|
||||||
|
if (!SOFT_DELETE_MODELS.includes(params.model || '')) {
|
||||||
|
return next(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operations to apply soft-delete filter to
|
||||||
|
const operations = ['findUnique', 'findFirst', 'findMany', 'count', 'aggregate'];
|
||||||
|
|
||||||
|
if (operations.includes(params.action)) {
|
||||||
|
// Initialize where clause if it doesn't exist
|
||||||
|
params.args.where = params.args.where || {};
|
||||||
|
|
||||||
|
// Only apply filter if deletedAt is not already specified
|
||||||
|
// This allows explicit queries for deleted records: { deletedAt: { not: null } }
|
||||||
|
// or to bypass middleware: { deletedAt: undefined }
|
||||||
|
if (!('deletedAt' in params.args.where)) {
|
||||||
|
params.args.where.deletedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For update/updateMany, ensure we don't accidentally update soft-deleted records
|
||||||
|
if (params.action === 'update' || params.action === 'updateMany') {
|
||||||
|
params.args.where = params.args.where || {};
|
||||||
|
|
||||||
|
// Only apply if not explicitly specified
|
||||||
|
if (!('deletedAt' in params.args.where)) {
|
||||||
|
params.args.where.deletedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
this.logger.log('Database disconnected');
|
this.logger.log('Database disconnected');
|
||||||
|
|||||||
@@ -215,60 +215,181 @@ export class SeedService {
|
|||||||
|
|
||||||
private getFlightData(vips: any[]) {
|
private getFlightData(vips: any[]) {
|
||||||
const flights: any[] = [];
|
const flights: any[] = [];
|
||||||
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
|
|
||||||
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
|
|
||||||
const destination = 'SLC';
|
const destination = 'SLC';
|
||||||
|
|
||||||
vips.forEach((vip, index) => {
|
// Build a name->vip lookup for named scenarios
|
||||||
const airline = airlines[index % airlines.length];
|
const vipByName = new Map<string, any>();
|
||||||
const flightNum = `${airline}${1000 + index * 123}`;
|
vips.forEach(v => vipByName.set(v.name, v));
|
||||||
const origin = origins[index % origins.length];
|
|
||||||
|
|
||||||
// Arrival flight - times relative to now
|
// Helper: create a flight record
|
||||||
const arrivalOffset = (index % 8) * 30 - 60;
|
const makeFlight = (vipId: string, opts: any) => ({
|
||||||
const scheduledArrival = this.relativeTime(arrivalOffset);
|
vipId,
|
||||||
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
|
flightDate: new Date(),
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
let status = 'scheduled';
|
// ============================================================
|
||||||
let actualArrival = null;
|
// NAMED MULTI-SEGMENT SCENARIOS
|
||||||
if (arrivalOffset < -30) {
|
// ============================================================
|
||||||
status = 'landed';
|
|
||||||
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
|
// Roger Krone: 3-segment journey, all landed cleanly
|
||||||
} else if (arrivalOffset < 0) {
|
// BWI -> ORD -> DEN -> SLC
|
||||||
status = 'landing';
|
const krone = vipByName.get('Roger A. Krone');
|
||||||
} else if (index % 5 === 0) {
|
if (krone) {
|
||||||
status = 'delayed';
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 1, flightNumber: 'UA410', departureAirport: 'BWI', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-240),
|
||||||
|
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-235),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 2, flightNumber: 'UA672', departureAirport: 'ORD', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(-150), scheduledArrival: this.relativeTime(-60),
|
||||||
|
actualDeparture: this.relativeTime(-148), actualArrival: this.relativeTime(-55),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(krone.id, {
|
||||||
|
segment: 3, flightNumber: 'UA1190', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-20), scheduledArrival: this.relativeTime(40),
|
||||||
|
actualDeparture: this.relativeTime(-18), actualArrival: null,
|
||||||
|
status: 'active',
|
||||||
|
arrivalTerminal: '2', arrivalGate: 'B7',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
flights.push({
|
// Sarah Chen: 2-segment, leg 1 landed, leg 2 active/arriving
|
||||||
vipId: vip.id,
|
// JFK -> ORD -> SLC
|
||||||
flightNumber: flightNum,
|
const chen = vipByName.get('Sarah Chen');
|
||||||
flightDate: new Date(),
|
if (chen) {
|
||||||
|
flights.push(makeFlight(chen.id, {
|
||||||
|
segment: 1, flightNumber: 'AA234', departureAirport: 'JFK', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-300), scheduledArrival: this.relativeTime(-180),
|
||||||
|
actualDeparture: this.relativeTime(-298), actualArrival: this.relativeTime(-175),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(chen.id, {
|
||||||
|
segment: 2, flightNumber: 'AA1456', departureAirport: 'ORD', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-90), scheduledArrival: this.relativeTime(30),
|
||||||
|
actualDeparture: this.relativeTime(-88), actualArrival: null,
|
||||||
|
status: 'active',
|
||||||
|
arrivalTerminal: '1', arrivalGate: 'A12',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roberto Gonzalez: 2-segment, leg 1 DELAYED 45min - threatens connection
|
||||||
|
// LAX -> DFW -> SLC (90min layover scheduled, now ~45min WARNING)
|
||||||
|
const gonzalez = vipByName.get('Roberto Gonzalez');
|
||||||
|
if (gonzalez) {
|
||||||
|
flights.push(makeFlight(gonzalez.id, {
|
||||||
|
segment: 1, flightNumber: 'DL890', departureAirport: 'LAX', arrivalAirport: 'DFW',
|
||||||
|
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
|
||||||
|
estimatedArrival: this.relativeTime(-45), // 45min late
|
||||||
|
actualDeparture: this.relativeTime(-195), // departed 45min late
|
||||||
|
departureDelay: 45, arrivalDelay: 45,
|
||||||
|
status: 'active', // still in the air
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(gonzalez.id, {
|
||||||
|
segment: 2, flightNumber: 'DL1522', departureAirport: 'DFW', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(0), scheduledArrival: this.relativeTime(150),
|
||||||
|
status: 'scheduled',
|
||||||
|
departureTerminal: 'E', departureGate: 'E14',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thomas Anderson: 2-segment, MISSED CONNECTION
|
||||||
|
// BOS -> ORD -> SLC (leg 1 arrived 25min late, leg 2 already departed)
|
||||||
|
const anderson = vipByName.get('Thomas Anderson');
|
||||||
|
if (anderson) {
|
||||||
|
flights.push(makeFlight(anderson.id, {
|
||||||
|
segment: 1, flightNumber: 'JB320', departureAirport: 'BOS', arrivalAirport: 'ORD',
|
||||||
|
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
|
||||||
|
actualDeparture: this.relativeTime(-215), actualArrival: this.relativeTime(-65),
|
||||||
|
departureDelay: 25, arrivalDelay: 25,
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(anderson.id, {
|
||||||
|
segment: 2, flightNumber: 'JB988', departureAirport: 'ORD', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-75), scheduledArrival: this.relativeTime(15),
|
||||||
|
actualDeparture: this.relativeTime(-75), // departed on time - before leg 1 landed
|
||||||
|
status: 'active', // plane left without him
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcus Johnson: 2-segment, both landed cleanly
|
||||||
|
// ATL -> DEN -> SLC
|
||||||
|
const johnson = vipByName.get('Marcus Johnson');
|
||||||
|
if (johnson) {
|
||||||
|
flights.push(makeFlight(johnson.id, {
|
||||||
|
segment: 1, flightNumber: 'DL512', departureAirport: 'ATL', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-210),
|
||||||
|
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-205),
|
||||||
|
status: 'landed',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(johnson.id, {
|
||||||
|
segment: 2, flightNumber: 'DL1780', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(-120), scheduledArrival: this.relativeTime(-30),
|
||||||
|
actualDeparture: this.relativeTime(-118), actualArrival: this.relativeTime(-25),
|
||||||
|
status: 'landed',
|
||||||
|
arrivalTerminal: '2', arrivalGate: 'C4', arrivalBaggage: '3',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// James O'Brien: 2-segment, both scheduled (future)
|
||||||
|
// DFW -> DEN -> SLC
|
||||||
|
const obrien = vipByName.get("James O'Brien");
|
||||||
|
if (obrien) {
|
||||||
|
flights.push(makeFlight(obrien.id, {
|
||||||
|
segment: 1, flightNumber: 'UA780', departureAirport: 'DFW', arrivalAirport: 'DEN',
|
||||||
|
scheduledDeparture: this.relativeTime(60), scheduledArrival: this.relativeTime(180),
|
||||||
|
status: 'scheduled',
|
||||||
|
}));
|
||||||
|
flights.push(makeFlight(obrien.id, {
|
||||||
|
segment: 2, flightNumber: 'UA1340', departureAirport: 'DEN', arrivalAirport: destination,
|
||||||
|
scheduledDeparture: this.relativeTime(240), scheduledArrival: this.relativeTime(330),
|
||||||
|
status: 'scheduled',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DIRECT FLIGHTS (single segment)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const directFlights: Array<{ name: string; airline: string; num: string; origin: string; offset: number; statusOverride?: string }> = [
|
||||||
|
{ name: 'Jennifer Wu', airline: 'AA', num: 'AA1023', origin: 'ORD', offset: 60 },
|
||||||
|
{ name: 'Priya Sharma', airline: 'UA', num: 'UA567', origin: 'SFO', offset: -15, statusOverride: 'active' },
|
||||||
|
{ name: 'David Okonkwo', airline: 'DL', num: 'DL1345', origin: 'SEA', offset: 120 },
|
||||||
|
{ name: 'Yuki Tanaka', airline: 'AA', num: 'AA890', origin: 'LAX', offset: 90 },
|
||||||
|
{ name: 'Isabella Costa', airline: 'SW', num: 'SW2210', origin: 'MIA', offset: -45, statusOverride: 'active' },
|
||||||
|
{ name: 'Fatima Al-Rahman', airline: 'AS', num: 'AS440', origin: 'SEA', offset: 180 },
|
||||||
|
{ name: 'William Zhang', airline: 'DL', num: 'DL1678', origin: 'ATL', offset: -90, statusOverride: 'landed' },
|
||||||
|
{ name: 'Alexander Volkov', airline: 'UA', num: 'UA2100', origin: 'DEN', offset: 45 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const df of directFlights) {
|
||||||
|
const vip = vipByName.get(df.name);
|
||||||
|
if (!vip) continue;
|
||||||
|
|
||||||
|
const scheduledArrival = this.relativeTime(df.offset);
|
||||||
|
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
let status = df.statusOverride || 'scheduled';
|
||||||
|
let actualArrival = null;
|
||||||
|
if (status === 'landed') {
|
||||||
|
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
flights.push(makeFlight(vip.id, {
|
||||||
segment: 1,
|
segment: 1,
|
||||||
departureAirport: origin,
|
flightNumber: df.num,
|
||||||
|
departureAirport: df.origin,
|
||||||
arrivalAirport: destination,
|
arrivalAirport: destination,
|
||||||
scheduledDeparture,
|
scheduledDeparture,
|
||||||
scheduledArrival,
|
scheduledArrival,
|
||||||
|
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
|
||||||
actualArrival,
|
actualArrival,
|
||||||
status,
|
status,
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Some VIPs have connecting flights (segment 2)
|
|
||||||
if (index % 4 === 0) {
|
|
||||||
const connectOrigin = origins[(index + 3) % origins.length];
|
|
||||||
flights.push({
|
|
||||||
vipId: vip.id,
|
|
||||||
flightNumber: `${airline}${500 + index}`,
|
|
||||||
flightDate: new Date(),
|
|
||||||
segment: 2,
|
|
||||||
departureAirport: connectOrigin,
|
|
||||||
arrivalAirport: origin,
|
|
||||||
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
|
|
||||||
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
|
|
||||||
status: 'landed',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return flights;
|
return flights;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ export class SettingsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app timezone - any authenticated user can read this
|
||||||
|
*/
|
||||||
|
@Get('timezone')
|
||||||
|
getTimezone() {
|
||||||
|
return this.settingsService.getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update app timezone - admin only
|
||||||
|
*/
|
||||||
|
@Patch('timezone')
|
||||||
|
@CanUpdate('Settings')
|
||||||
|
updateTimezone(@Body() dto: { timezone: string }) {
|
||||||
|
return this.settingsService.updateTimezone(dto.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('pdf')
|
@Get('pdf')
|
||||||
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||||
getPdfSettings() {
|
getPdfSettings() {
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app-wide timezone setting
|
||||||
|
*/
|
||||||
|
async getTimezone(): Promise<{ timezone: string }> {
|
||||||
|
const settings = await this.getPdfSettings();
|
||||||
|
return { timezone: settings.timezone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the app-wide timezone setting
|
||||||
|
*/
|
||||||
|
async updateTimezone(timezone: string): Promise<{ timezone: string }> {
|
||||||
|
this.logger.log(`Updating timezone to: ${timezone}`);
|
||||||
|
|
||||||
|
// Validate the timezone string
|
||||||
|
try {
|
||||||
|
Intl.DateTimeFormat(undefined, { timeZone: timezone });
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid timezone: ${timezone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.getPdfSettings();
|
||||||
|
|
||||||
|
await this.prisma.pdfSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { timezone },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { timezone };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload logo as base64 data URL
|
* Upload logo as base64 data URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { Public } from '../auth/decorators/public.decorator';
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
import { MessagesService, SendMessageDto } from './messages.service';
|
import { MessagesService, SendMessageDto } from './messages.service';
|
||||||
|
import { toDateString } from '../common/utils/date.utils';
|
||||||
|
|
||||||
// DTO for incoming Signal webhook
|
// DTO for incoming Signal webhook
|
||||||
interface SignalWebhookPayload {
|
interface SignalWebhookPayload {
|
||||||
@@ -154,7 +155,7 @@ export class MessagesController {
|
|||||||
async exportMessages(@Res() res: Response) {
|
async exportMessages(@Res() res: Response) {
|
||||||
const exportData = await this.messagesService.exportAllMessages();
|
const exportData = await this.messagesService.exportAllMessages();
|
||||||
|
|
||||||
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
|
const filename = `signal-chats-${toDateString(new Date())}.txt`;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class MessagesService {
|
|||||||
*/
|
*/
|
||||||
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: driverId, deletedAt: null },
|
where: { id: driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -55,7 +55,7 @@ export class MessagesService {
|
|||||||
*/
|
*/
|
||||||
async sendMessage(dto: SendMessageDto) {
|
async sendMessage(dto: SendMessageDto) {
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: { id: dto.driverId, deletedAt: null },
|
where: { id: dto.driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
@@ -113,7 +113,6 @@ export class MessagesService {
|
|||||||
// Find driver by phone number
|
// Find driver by phone number
|
||||||
const driver = await this.prisma.driver.findFirst({
|
const driver = await this.prisma.driver.findFirst({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
OR: [
|
OR: [
|
||||||
{ phone: fromNumber },
|
{ phone: fromNumber },
|
||||||
{ phone: normalizedPhone },
|
{ phone: normalizedPhone },
|
||||||
@@ -172,7 +171,6 @@ export class MessagesService {
|
|||||||
where: {
|
where: {
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
status: EventStatus.IN_PROGRESS,
|
status: EventStatus.IN_PROGRESS,
|
||||||
deletedAt: null,
|
|
||||||
},
|
},
|
||||||
include: { vehicle: true },
|
include: { vehicle: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export class UsersService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -19,7 +18,7 @@ export class UsersService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: { driver: true },
|
include: { driver: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ export class UsersService {
|
|||||||
async getPendingUsers() {
|
async getPendingUsers() {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('vehicles')
|
@Controller('vehicles')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@@ -59,10 +60,9 @@ export class VehiclesController {
|
|||||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
const isHardDelete = hard === 'true';
|
return this.vehiclesService.remove(id, hard, user?.role);
|
||||||
return this.vehiclesService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
private readonly logger = new Logger(VehiclesService.name);
|
private readonly logger = new Logger(VehiclesService.name);
|
||||||
|
|
||||||
|
private readonly vehicleInclude = {
|
||||||
|
currentDriver: true,
|
||||||
|
events: {
|
||||||
|
include: { driver: true, vehicle: true },
|
||||||
|
orderBy: { startTime: 'asc' as const },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async create(createVehicleDto: CreateVehicleDto) {
|
async create(createVehicleDto: CreateVehicleDto) {
|
||||||
@@ -13,27 +22,13 @@ export class VehiclesService {
|
|||||||
|
|
||||||
return this.prisma.vehicle.create({
|
return this.prisma.vehicle.create({
|
||||||
data: createVehicleDto,
|
data: createVehicleDto,
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.vehicle.findMany({
|
return this.prisma.vehicle.findMany({
|
||||||
where: { deletedAt: null },
|
include: this.vehicleInclude,
|
||||||
include: {
|
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,7 +36,6 @@ export class VehiclesService {
|
|||||||
async findAvailable() {
|
async findAvailable() {
|
||||||
return this.prisma.vehicle.findMany({
|
return this.prisma.vehicle.findMany({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -53,15 +47,8 @@ export class VehiclesService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const vehicle = await this.prisma.vehicle.findFirst({
|
const vehicle = await this.prisma.vehicle.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!vehicle) {
|
if (!vehicle) {
|
||||||
@@ -79,34 +66,24 @@ export class VehiclesService {
|
|||||||
return this.prisma.vehicle.update({
|
return this.prisma.vehicle.update({
|
||||||
where: { id: vehicle.id },
|
where: { id: vehicle.id },
|
||||||
data: updateVehicleDto,
|
data: updateVehicleDto,
|
||||||
include: {
|
include: this.vehicleInclude,
|
||||||
currentDriver: true,
|
|
||||||
events: {
|
|
||||||
where: { deletedAt: null },
|
|
||||||
include: { driver: true, vehicle: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const vehicle = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting vehicle: ${vehicle.name}`);
|
this.prisma.vehicle.update({
|
||||||
return this.prisma.vehicle.delete({
|
where: { id },
|
||||||
where: { id: vehicle.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting vehicle: ${vehicle.name}`);
|
|
||||||
return this.prisma.vehicle.update({
|
|
||||||
where: { id: vehicle.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'Vehicle',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,23 +91,32 @@ export class VehiclesService {
|
|||||||
* Get vehicle utilization statistics
|
* Get vehicle utilization statistics
|
||||||
*/
|
*/
|
||||||
async getUtilization() {
|
async getUtilization() {
|
||||||
const vehicles = await this.findAll();
|
const now = new Date();
|
||||||
|
|
||||||
const stats = vehicles.map((vehicle) => {
|
// Fetch vehicles with only upcoming events (filtered at database level)
|
||||||
const upcomingEvents = vehicle.events.filter(
|
const vehicles = await this.prisma.vehicle.findMany({
|
||||||
(event) => new Date(event.startTime) > new Date(),
|
include: {
|
||||||
);
|
currentDriver: true,
|
||||||
|
events: {
|
||||||
|
where: {
|
||||||
|
startTime: { gt: now }, // Only fetch upcoming events
|
||||||
|
},
|
||||||
|
include: { driver: true, vehicle: true },
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
const stats = vehicles.map((vehicle) => ({
|
||||||
id: vehicle.id,
|
id: vehicle.id,
|
||||||
name: vehicle.name,
|
name: vehicle.name,
|
||||||
type: vehicle.type,
|
type: vehicle.type,
|
||||||
seatCapacity: vehicle.seatCapacity,
|
seatCapacity: vehicle.seatCapacity,
|
||||||
status: vehicle.status,
|
status: vehicle.status,
|
||||||
upcomingTrips: upcomingEvents.length,
|
upcomingTrips: vehicle.events.length, // Already filtered at DB level
|
||||||
currentDriver: vehicle.currentDriver?.name,
|
currentDriver: vehicle.currentDriver?.name,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalVehicles: vehicles.length,
|
totalVehicles: vehicles.length,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard';
|
|||||||
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
|
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||||
|
import { ParseBooleanPipe } from '../common/pipes';
|
||||||
|
|
||||||
@Controller('vips')
|
@Controller('vips')
|
||||||
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||||
@@ -49,11 +50,9 @@ export class VipsController {
|
|||||||
@CanDelete('VIP')
|
@CanDelete('VIP')
|
||||||
remove(
|
remove(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Query('hard') hard?: string,
|
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||||
@CurrentUser() user?: any,
|
@CurrentUser() user?: any,
|
||||||
) {
|
) {
|
||||||
// Only administrators can hard delete
|
return this.vipsService.remove(id, hard, user?.role);
|
||||||
const isHardDelete = hard === 'true';
|
|
||||||
return this.vipsService.remove(id, isHardDelete, user?.role);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||||
|
import { executeHardDelete } from '../common/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VipsService {
|
export class VipsService {
|
||||||
@@ -21,7 +22,6 @@ export class VipsService {
|
|||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.vIP.findMany({
|
return this.prisma.vIP.findMany({
|
||||||
where: { deletedAt: null },
|
|
||||||
include: {
|
include: {
|
||||||
flights: true,
|
flights: true,
|
||||||
},
|
},
|
||||||
@@ -31,7 +31,7 @@ export class VipsService {
|
|||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const vip = await this.prisma.vIP.findFirst({
|
const vip = await this.prisma.vIP.findFirst({
|
||||||
where: { id, deletedAt: null },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
flights: true,
|
flights: true,
|
||||||
},
|
},
|
||||||
@@ -59,23 +59,19 @@ export class VipsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, hardDelete = false, userRole?: string) {
|
async remove(id: string, hardDelete = false, userRole?: string) {
|
||||||
if (hardDelete && userRole !== 'ADMINISTRATOR') {
|
return executeHardDelete({
|
||||||
throw new ForbiddenException('Only administrators can permanently delete records');
|
id,
|
||||||
}
|
hardDelete,
|
||||||
|
userRole,
|
||||||
const vip = await this.findOne(id);
|
findOne: (id) => this.findOne(id),
|
||||||
|
performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }),
|
||||||
if (hardDelete) {
|
performSoftDelete: (id) =>
|
||||||
this.logger.log(`Hard deleting VIP: ${vip.name}`);
|
this.prisma.vIP.update({
|
||||||
return this.prisma.vIP.delete({
|
where: { id },
|
||||||
where: { id: vip.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Soft deleting VIP: ${vip.name}`);
|
|
||||||
return this.prisma.vIP.update({
|
|
||||||
where: { id: vip.id },
|
|
||||||
data: { deletedAt: new Date() },
|
data: { deletedAt: new Date() },
|
||||||
|
}),
|
||||||
|
entityName: 'VIP',
|
||||||
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
|
|||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider } from '@/contexts/AuthContext';
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
import { AbilityProvider } from '@/contexts/AbilityContext';
|
import { AbilityProvider } from '@/contexts/AbilityContext';
|
||||||
|
import { TimezoneProvider } from '@/contexts/TimezoneContext';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
@@ -13,7 +14,7 @@ import { Callback } from '@/pages/Callback';
|
|||||||
import { PendingApproval } from '@/pages/PendingApproval';
|
import { PendingApproval } from '@/pages/PendingApproval';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { CommandCenter } from '@/pages/CommandCenter';
|
import { CommandCenter } from '@/pages/CommandCenter';
|
||||||
import { VIPList } from '@/pages/VipList';
|
import { VIPList } from '@/pages/VIPList';
|
||||||
import { VIPSchedule } from '@/pages/VIPSchedule';
|
import { VIPSchedule } from '@/pages/VIPSchedule';
|
||||||
import { FleetPage } from '@/pages/FleetPage';
|
import { FleetPage } from '@/pages/FleetPage';
|
||||||
import { EventList } from '@/pages/EventList';
|
import { EventList } from '@/pages/EventList';
|
||||||
@@ -68,6 +69,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<TimezoneProvider>
|
||||||
<AbilityProvider>
|
<AbilityProvider>
|
||||||
<BrowserRouter
|
<BrowserRouter
|
||||||
future={{
|
future={{
|
||||||
@@ -138,6 +140,7 @@ function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AbilityProvider>
|
</AbilityProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Auth0Provider>
|
</Auth0Provider>
|
||||||
|
|||||||
524
frontend/src/components/AccountabilityRosterPDF.tsx
Normal file
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 { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Send, Loader2 } from 'lucide-react';
|
import { X, Send, Loader2 } from 'lucide-react';
|
||||||
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
||||||
const sendMessage = useSendMessage();
|
const sendMessage = useSendMessage();
|
||||||
@@ -66,22 +68,6 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div
|
<div
|
||||||
@@ -126,7 +112,7 @@ export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProp
|
|||||||
<p className={`text-[10px] mt-1 ${
|
<p className={`text-[10px] mt-1 ${
|
||||||
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||||
}`}>
|
}`}>
|
||||||
{formatTime(msg.timestamp)}
|
{formatDateTime(msg.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
interface DriverFormProps {
|
interface DriverFormProps {
|
||||||
driver?: Driver | null;
|
driver?: Driver | null;
|
||||||
@@ -112,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"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">Select Department</option>
|
<option value="">Select Department</option>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||||
<option value="ADMIN">Admin</option>
|
<option key={value} value={value}>
|
||||||
<option value="OTHER">Other</option>
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface ScheduleEvent {
|
interface ScheduleEvent {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +37,7 @@ interface DriverScheduleModalProps {
|
|||||||
|
|
||||||
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const { formatDate, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
const dateString = selectedDate.toISOString().split('T')[0];
|
const dateString = selectedDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
@@ -85,23 +87,6 @@ export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleM
|
|||||||
|
|
||||||
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'COMPLETED':
|
case 'COMPLETED':
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
import { toDatetimeLocal } from '@/lib/utils';
|
||||||
|
import { EVENT_TYPE_LABELS, EVENT_STATUS_LABELS } from '@/lib/enum-labels';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
event?: ScheduleEvent | null;
|
event?: ScheduleEvent | null;
|
||||||
@@ -39,17 +42,7 @@ interface ScheduleConflict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
const { formatDateTime } = useFormattedDate();
|
||||||
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
|
||||||
if (!isoString) return '';
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<EventFormData>({
|
const [formData, setFormData] = useState<EventFormData>({
|
||||||
vipIds: event?.vipIds || [],
|
vipIds: event?.vipIds || [],
|
||||||
@@ -75,7 +68,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch VIPs for selection
|
// Fetch VIPs for selection
|
||||||
const { data: vips } = useQuery<VIP[]>({
|
const { data: vips } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: queryKeys.vips.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/vips');
|
const { data } = await api.get('/vips');
|
||||||
return data;
|
return data;
|
||||||
@@ -84,7 +77,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch Drivers for dropdown
|
// Fetch Drivers for dropdown
|
||||||
const { data: drivers } = useQuery<Driver[]>({
|
const { data: drivers } = useQuery<Driver[]>({
|
||||||
queryKey: ['drivers'],
|
queryKey: queryKeys.drivers.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/drivers');
|
const { data } = await api.get('/drivers');
|
||||||
return data;
|
return data;
|
||||||
@@ -93,7 +86,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch Vehicles for dropdown
|
// Fetch Vehicles for dropdown
|
||||||
const { data: vehicles } = useQuery<Vehicle[]>({
|
const { data: vehicles } = useQuery<Vehicle[]>({
|
||||||
queryKey: ['vehicles'],
|
queryKey: queryKeys.vehicles.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/vehicles');
|
const { data } = await api.get('/vehicles');
|
||||||
return data;
|
return data;
|
||||||
@@ -102,7 +95,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
|
|
||||||
// Fetch all events (for master event selector)
|
// Fetch all events (for master event selector)
|
||||||
const { data: allEvents } = useQuery<ScheduleEvent[]>({
|
const { data: allEvents } = useQuery<ScheduleEvent[]>({
|
||||||
queryKey: ['events'],
|
queryKey: queryKeys.events.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/events');
|
const { data } = await api.get('/events');
|
||||||
return data;
|
return data;
|
||||||
@@ -217,10 +210,12 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedVipNames = vips
|
const selectedVipNames = useMemo(() => {
|
||||||
|
return vips
|
||||||
?.filter(vip => formData.vipIds.includes(vip.id))
|
?.filter(vip => formData.vipIds.includes(vip.id))
|
||||||
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
|
.map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name)
|
||||||
.join(', ') || 'None selected';
|
.join(', ') || 'None selected';
|
||||||
|
}, [vips, formData.vipIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -450,11 +445,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="TRANSPORT">Transport</option>
|
{Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => (
|
||||||
<option value="MEETING">Meeting</option>
|
<option key={value} value={value}>
|
||||||
<option value="EVENT">Event</option>
|
{label}
|
||||||
<option value="MEAL">Meal</option>
|
</option>
|
||||||
<option value="ACCOMMODATION">Accommodation</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -468,10 +463,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="SCHEDULED">Scheduled</option>
|
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
|
||||||
<option value="IN_PROGRESS">In Progress</option>
|
<option key={value} value={value}>
|
||||||
<option value="COMPLETED">Completed</option>
|
{label}
|
||||||
<option value="CANCELLED">Cancelled</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
ToggleLeft,
|
|
||||||
ToggleRight,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Users,
|
Users,
|
||||||
|
CheckCircle,
|
||||||
|
Link2,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Flight } from '@/types';
|
import { Flight, Journey, Layover } from '@/types';
|
||||||
import { FlightProgressBar } from './FlightProgressBar';
|
import { FlightProgressBar } from './FlightProgressBar';
|
||||||
import { useRefreshFlight } from '@/hooks/useFlights';
|
import { useRefreshFlight } from '@/hooks/useFlights';
|
||||||
|
import { formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface FlightCardProps {
|
interface FlightCardProps {
|
||||||
flight: Flight;
|
flight?: Flight;
|
||||||
|
journey?: Journey;
|
||||||
onEdit?: (flight: Flight) => void;
|
onEdit?: (flight: Flight) => void;
|
||||||
onDelete?: (flight: Flight) => void;
|
onDelete?: (flight: Flight) => void;
|
||||||
}
|
}
|
||||||
@@ -58,16 +62,75 @@ function formatRelativeTime(isoString: string | null): string {
|
|||||||
return `${Math.floor(hours / 24)}d ago`;
|
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 [expanded, setExpanded] = useState(false);
|
||||||
const refreshMutation = useRefreshFlight();
|
const refreshMutation = useRefreshFlight();
|
||||||
const alert = getAlertBanner(flight);
|
const alert = getAlertBanner(flight);
|
||||||
const dotColor = getStatusDotColor(flight);
|
const dotColor = getStatusDotColor(flight);
|
||||||
const isTerminal = ['landed', 'cancelled', 'diverted', 'incident'].includes(flight.status?.toLowerCase() || '');
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
{/* Alert banner */}
|
|
||||||
{alert && (
|
{alert && (
|
||||||
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${alert.color}`}>
|
<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" />
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
@@ -75,22 +138,16 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-4 pt-3 pb-1">
|
<div className="px-4 pt-3 pb-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Status dot */}
|
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
|
||||||
{/* Flight number + airline */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
<span className="font-bold text-foreground">{flight.flightNumber}</span>
|
||||||
{flight.airlineName && (
|
{flight.airlineName && (
|
||||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VIP name */}
|
|
||||||
{flight.vip && (
|
{flight.vip && (
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span className="text-muted-foreground/50">|</span>
|
<span className="text-muted-foreground/50">|</span>
|
||||||
@@ -104,8 +161,6 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshMutation.mutate(flight.id)}
|
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' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button onClick={() => onEdit(flight)} className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground" title="Edit flight">
|
||||||
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" />
|
<Edit3 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<button
|
<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">
|
||||||
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -137,12 +184,10 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<FlightProgressBar flight={flight} />
|
<FlightProgressBar flight={flight} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer - expandable details */}
|
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
@@ -165,14 +210,13 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="pt-2 pb-1 border-t border-border/50 space-y-2 text-xs">
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground mb-1">Departure</div>
|
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||||
<div className="space-y-0.5 text-muted-foreground">
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
{flight.scheduledDeparture && <div>Scheduled: {new Date(flight.scheduledDeparture).toLocaleString()}</div>}
|
{flight.scheduledDeparture && <div>Scheduled: {formatDateTime(flight.scheduledDeparture)}</div>}
|
||||||
{flight.estimatedDeparture && <div>Estimated: {new Date(flight.estimatedDeparture).toLocaleString()}</div>}
|
{flight.estimatedDeparture && <div>Estimated: {formatDateTime(flight.estimatedDeparture)}</div>}
|
||||||
{flight.actualDeparture && <div className="text-foreground">Actual: {new Date(flight.actualDeparture).toLocaleString()}</div>}
|
{flight.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(flight.actualDeparture)}</div>}
|
||||||
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
{flight.departureDelay != null && flight.departureDelay > 0 && (
|
||||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.departureDelay} min</div>
|
<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>
|
||||||
<div className="font-medium text-foreground mb-1">Arrival</div>
|
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||||
<div className="space-y-0.5 text-muted-foreground">
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
{flight.scheduledArrival && <div>Scheduled: {new Date(flight.scheduledArrival).toLocaleString()}</div>}
|
{flight.scheduledArrival && <div>Scheduled: {formatDateTime(flight.scheduledArrival)}</div>}
|
||||||
{flight.estimatedArrival && <div>Estimated: {new Date(flight.estimatedArrival).toLocaleString()}</div>}
|
{flight.estimatedArrival && <div>Estimated: {formatDateTime(flight.estimatedArrival)}</div>}
|
||||||
{flight.actualArrival && <div className="text-foreground">Actual: {new Date(flight.actualArrival).toLocaleString()}</div>}
|
{flight.actualArrival && <div className="text-foreground">Actual: {formatDateTime(flight.actualArrival)}</div>}
|
||||||
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
{flight.arrivalDelay != null && flight.arrivalDelay > 0 && (
|
||||||
<div className="text-amber-600 dark:text-amber-400">Delay: {flight.arrivalDelay} min</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aircraft info */}
|
|
||||||
{flight.aircraftType && (
|
{flight.aircraftType && (
|
||||||
<div className="text-muted-foreground">
|
<div className="text-muted-foreground">Aircraft: {flight.aircraftType}</div>
|
||||||
Aircraft: {flight.aircraftType}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -208,3 +248,220 @@ export function FlightCard({ flight, onEdit, onDelete }: FlightCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MULTI-SEGMENT JOURNEY CARD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function JourneyCard({ journey, onEdit, onDelete }: { journey: Journey; onEdit?: (f: Flight) => void; onDelete?: (f: Flight) => void }) {
|
||||||
|
const [expandedSeg, setExpandedSeg] = useState<number | null>(null);
|
||||||
|
const refreshMutation = useRefreshFlight();
|
||||||
|
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||||
|
const dotColor = getStatusDotColor(currentFlight);
|
||||||
|
const vip = journey.vip || currentFlight?.vip;
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
|
|
||||||
|
// Route chain: BWI -> ORD -> SLC
|
||||||
|
const routeChain = [journey.origin, ...journey.flights.slice(1).map(f => f.departureAirport), journey.destination]
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i); // dedupe
|
||||||
|
|
||||||
|
// Connection risk banner
|
||||||
|
const worstLayover = journey.layovers.reduce<Layover | null>((worst, l) => {
|
||||||
|
if (!worst) return l;
|
||||||
|
if (l.risk === 'missed') return l;
|
||||||
|
if (l.risk === 'critical' && worst.risk !== 'missed') return l;
|
||||||
|
if (l.risk === 'warning' && worst.risk !== 'missed' && worst.risk !== 'critical') return l;
|
||||||
|
return worst;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
const connectionBanner = worstLayover && (worstLayover.risk === 'warning' || worstLayover.risk === 'critical' || worstLayover.risk === 'missed')
|
||||||
|
? {
|
||||||
|
message: worstLayover.risk === 'missed'
|
||||||
|
? `CONNECTION MISSED at ${worstLayover.airport}`
|
||||||
|
: worstLayover.risk === 'critical'
|
||||||
|
? `CONNECTION AT RISK - only ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`
|
||||||
|
: `Connection tight - ${formatLayoverDuration(worstLayover.effectiveMinutes)} at ${worstLayover.airport}`,
|
||||||
|
color: worstLayover.risk === 'missed'
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||||
|
: worstLayover.risk === 'critical'
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
|
||||||
|
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden transition-shadow hover:shadow-medium">
|
||||||
|
{/* Connection risk banner */}
|
||||||
|
{connectionBanner && (
|
||||||
|
<div className={`px-4 py-1.5 text-xs font-semibold border-b flex items-center gap-2 ${connectionBanner.color}`}>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
{connectionBanner.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Journey header */}
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${dotColor}`} />
|
||||||
|
{vip && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-foreground">{vip.name}</span>
|
||||||
|
{vip.partySize > 1 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
+{vip.partySize - 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Route chain */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{routeChain.map((code, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-muted-foreground/40">{'→'}</span>}
|
||||||
|
<span className={i === 0 || i === routeChain.length - 1 ? 'font-bold text-foreground' : ''}>{code}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||||
|
{journey.flights.length} legs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment stack */}
|
||||||
|
{journey.flights.map((seg, i) => {
|
||||||
|
const isExpanded = expandedSeg === i;
|
||||||
|
const isCurrent = i === journey.currentSegmentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={seg.id}>
|
||||||
|
{/* Layover row between segments */}
|
||||||
|
{i > 0 && journey.layovers[i - 1] && (
|
||||||
|
<LayoverRow layover={journey.layovers[i - 1]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segment row */}
|
||||||
|
<div className={`px-4 py-2 ${isCurrent ? 'bg-accent/30' : ''}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Leg label + status icon */}
|
||||||
|
<div className="flex items-center gap-1.5 min-w-[60px]">
|
||||||
|
<span className="text-[10px] font-semibold text-muted-foreground uppercase">Leg {i + 1}</span>
|
||||||
|
{getSegmentStatusIcon(seg)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact progress bar */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<FlightProgressBar flight={seg} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight number + actions */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{seg.flightNumber}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => refreshMutation.mutate(seg.id)}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedSeg(isExpanded ? null : i)}
|
||||||
|
className="p-1 rounded hover:bg-accent transition-colors text-muted-foreground"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal/gate info for current segment */}
|
||||||
|
{isCurrent && (seg.arrivalTerminal || seg.arrivalGate || seg.arrivalBaggage) && (
|
||||||
|
<div className="flex gap-3 mt-1 ml-[68px] text-[10px] text-muted-foreground">
|
||||||
|
{seg.arrivalTerminal && <span>Terminal {seg.arrivalTerminal}</span>}
|
||||||
|
{seg.arrivalGate && <span>Gate {seg.arrivalGate}</span>}
|
||||||
|
{seg.arrivalBaggage && <span>Baggage {seg.arrivalBaggage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded details */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/50 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground mb-1">Departure</div>
|
||||||
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
|
{seg.scheduledDeparture && <div>Scheduled: {formatDateTime(seg.scheduledDeparture)}</div>}
|
||||||
|
{seg.actualDeparture && <div className="text-foreground">Actual: {formatDateTime(seg.actualDeparture)}</div>}
|
||||||
|
{seg.departureDelay != null && seg.departureDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.departureDelay} min</div>
|
||||||
|
)}
|
||||||
|
{seg.departureTerminal && <div>Terminal: {seg.departureTerminal}</div>}
|
||||||
|
{seg.departureGate && <div>Gate: {seg.departureGate}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground mb-1">Arrival</div>
|
||||||
|
<div className="space-y-0.5 text-muted-foreground">
|
||||||
|
{seg.scheduledArrival && <div>Scheduled: {formatDateTime(seg.scheduledArrival)}</div>}
|
||||||
|
{seg.actualArrival && <div className="text-foreground">Actual: {formatDateTime(seg.actualArrival)}</div>}
|
||||||
|
{seg.arrivalDelay != null && seg.arrivalDelay > 0 && (
|
||||||
|
<div className="text-amber-600 dark:text-amber-400">Delay: {seg.arrivalDelay} min</div>
|
||||||
|
)}
|
||||||
|
{seg.arrivalTerminal && <div>Terminal: {seg.arrivalTerminal}</div>}
|
||||||
|
{seg.arrivalGate && <div>Gate: {seg.arrivalGate}</div>}
|
||||||
|
{seg.arrivalBaggage && <div>Baggage: {seg.arrivalBaggage}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button onClick={() => onEdit(seg)} className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400">Edit</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button onClick={() => onDelete(seg)} className="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-1.5 border-t border-border/50 text-xs text-muted-foreground flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Updated {formatRelativeTime(currentFlight?.lastPolledAt)}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted text-[10px] uppercase tracking-wider font-medium">
|
||||||
|
{journey.effectiveStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EXPORT: Routes to single or journey card
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function FlightCard({ flight, journey, onEdit, onDelete }: FlightCardProps) {
|
||||||
|
if (journey) {
|
||||||
|
// Multi-segment journeys always use JourneyCard, single-segment journeys too when passed as journey
|
||||||
|
if (journey.isMultiSegment) {
|
||||||
|
return <JourneyCard journey={journey} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
// Single-segment journey: render as single flight card
|
||||||
|
return <SingleFlightCard flight={journey.flights[0]} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flight) {
|
||||||
|
return <SingleFlightCard flight={flight} onEdit={onEdit} onDelete={onDelete} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useEffect, useState } from 'react';
|
import { useMemo, useEffect, useState } from 'react';
|
||||||
import { Plane } from 'lucide-react';
|
import { Plane } from 'lucide-react';
|
||||||
import { Flight } from '@/types';
|
import { Flight } from '@/types';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface FlightProgressBarProps {
|
interface FlightProgressBarProps {
|
||||||
flight: Flight;
|
flight: Flight;
|
||||||
@@ -59,16 +60,8 @@ function getTrackBgColor(flight: Flight): string {
|
|||||||
return 'bg-muted';
|
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) {
|
export function FlightProgressBar({ flight, compact = false }: FlightProgressBarProps) {
|
||||||
|
const { formatTime } = useFormattedDate();
|
||||||
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
const [progress, setProgress] = useState(() => calculateProgress(flight));
|
||||||
const status = flight.status?.toLowerCase();
|
const status = flight.status?.toLowerCase();
|
||||||
const isActive = status === 'active';
|
const isActive = status === 'active';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
import { ChevronDown, AlertTriangle, X } from 'lucide-react';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Driver {
|
interface Driver {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +32,7 @@ export function InlineDriverSelector({
|
|||||||
currentDriverName,
|
currentDriverName,
|
||||||
onDriverChange,
|
onDriverChange,
|
||||||
}: InlineDriverSelectorProps) {
|
}: InlineDriverSelectorProps) {
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
const [showConflictDialog, setShowConflictDialog] = useState(false);
|
||||||
|
|||||||
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 { useState } from 'react';
|
||||||
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
||||||
|
import { toDatetimeLocal } from '@/lib/utils';
|
||||||
|
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
interface VIPFormProps {
|
interface VIPFormProps {
|
||||||
vip?: VIP | null;
|
vip?: VIP | null;
|
||||||
@@ -44,18 +46,6 @@ export interface VIPFormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
|
||||||
const toDatetimeLocal = (isoString: string | null) => {
|
|
||||||
if (!isoString) return '';
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<VIPFormData>({
|
const [formData, setFormData] = useState<VIPFormData>({
|
||||||
name: vip?.name || '',
|
name: vip?.name || '',
|
||||||
organization: vip?.organization || '',
|
organization: vip?.organization || '',
|
||||||
@@ -194,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"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||||
<option value="ADMIN">Admin</option>
|
<option key={value} value={value}>
|
||||||
<option value="OTHER">Other</option>
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="FLIGHT">Flight</option>
|
{Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => (
|
||||||
<option value="SELF_DRIVING">Self Driving</option>
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
71
frontend/src/contexts/TimezoneContext.tsx
Normal file
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 { api } from '@/lib/api';
|
||||||
import { Flight, FlightBudget } from '@/types';
|
import { Flight, FlightBudget } from '@/types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
export function useFlights() {
|
export function useFlights() {
|
||||||
return useQuery<Flight[]>({
|
return useQuery<Flight[]>({
|
||||||
queryKey: ['flights'],
|
queryKey: queryKeys.flights.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/flights');
|
const { data } = await api.get('/flights');
|
||||||
return data;
|
return data;
|
||||||
@@ -16,7 +17,7 @@ export function useFlights() {
|
|||||||
|
|
||||||
export function useFlightBudget() {
|
export function useFlightBudget() {
|
||||||
return useQuery<FlightBudget>({
|
return useQuery<FlightBudget>({
|
||||||
queryKey: ['flights', 'budget'],
|
queryKey: queryKeys.flights.budget,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/flights/tracking/budget');
|
const { data } = await api.get('/flights/tracking/budget');
|
||||||
return data;
|
return data;
|
||||||
@@ -34,8 +35,8 @@ export function useRefreshFlight() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
|
||||||
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
|
||||||
const status = data.status || 'unknown';
|
const status = data.status || 'unknown';
|
||||||
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
|
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
|
||||||
},
|
},
|
||||||
@@ -54,8 +55,8 @@ export function useRefreshActiveFlights() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
|
||||||
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
|
||||||
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
|
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
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,
|
GpsSettings,
|
||||||
EnrollmentResponse,
|
EnrollmentResponse,
|
||||||
MyGpsStatus,
|
MyGpsStatus,
|
||||||
|
DeviceQrInfo,
|
||||||
} from '@/types/gps';
|
} from '@/types/gps';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Admin GPS Hooks
|
// Admin GPS Hooks
|
||||||
@@ -20,7 +22,7 @@ import toast from 'react-hot-toast';
|
|||||||
*/
|
*/
|
||||||
export function useGpsStatus() {
|
export function useGpsStatus() {
|
||||||
return useQuery<GpsStatus>({
|
return useQuery<GpsStatus>({
|
||||||
queryKey: ['gps', 'status'],
|
queryKey: queryKeys.gps.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/status');
|
const { data } = await api.get('/gps/status');
|
||||||
return data;
|
return data;
|
||||||
@@ -34,7 +36,7 @@ export function useGpsStatus() {
|
|||||||
*/
|
*/
|
||||||
export function useGpsSettings() {
|
export function useGpsSettings() {
|
||||||
return useQuery<GpsSettings>({
|
return useQuery<GpsSettings>({
|
||||||
queryKey: ['gps', 'settings'],
|
queryKey: queryKeys.gps.settings,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/settings');
|
const { data } = await api.get('/gps/settings');
|
||||||
return data;
|
return data;
|
||||||
@@ -54,8 +56,8 @@ export function useUpdateGpsSettings() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
toast.success('GPS settings updated');
|
toast.success('GPS settings updated');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -69,7 +71,7 @@ export function useUpdateGpsSettings() {
|
|||||||
*/
|
*/
|
||||||
export function useGpsDevices() {
|
export function useGpsDevices() {
|
||||||
return useQuery<GpsDevice[]>({
|
return useQuery<GpsDevice[]>({
|
||||||
queryKey: ['gps', 'devices'],
|
queryKey: queryKeys.gps.devices,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/devices');
|
const { data } = await api.get('/gps/devices');
|
||||||
return data;
|
return data;
|
||||||
@@ -79,48 +81,30 @@ export function useGpsDevices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all active driver locations (for map)
|
* Get QR code info for an enrolled device (on demand)
|
||||||
|
*/
|
||||||
|
export function useDeviceQr(driverId: string | null) {
|
||||||
|
return useQuery<DeviceQrInfo>({
|
||||||
|
queryKey: driverId ? queryKeys.gps.deviceQr(driverId) : ['gps', 'devices', null, 'qr'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/gps/devices/${driverId}/qr`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!driverId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active driver locations (used by CommandCenter)
|
||||||
*/
|
*/
|
||||||
export function useDriverLocations() {
|
export function useDriverLocations() {
|
||||||
return useQuery<DriverLocation[]>({
|
return useQuery<DriverLocation[]>({
|
||||||
queryKey: ['gps', 'locations'],
|
queryKey: queryKeys.gps.locations.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/locations');
|
const { data } = await api.get('/gps/locations');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: 30000, // Refresh every 30 seconds
|
refetchInterval: 15000, // Refresh every 15 seconds
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific driver's location
|
|
||||||
*/
|
|
||||||
export function useDriverLocation(driverId: string) {
|
|
||||||
return useQuery<DriverLocation>({
|
|
||||||
queryKey: ['gps', 'locations', driverId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get(`/gps/locations/${driverId}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!driverId,
|
|
||||||
refetchInterval: 30000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get driver stats
|
|
||||||
*/
|
|
||||||
export function useDriverStats(driverId: string, from?: string, to?: string) {
|
|
||||||
return useQuery<DriverStats>({
|
|
||||||
queryKey: ['gps', 'stats', driverId, from, to],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (from) params.append('from', from);
|
|
||||||
if (to) params.append('to', to);
|
|
||||||
const { data } = await api.get(`/gps/stats/${driverId}?${params.toString()}`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!driverId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +120,9 @@ export function useEnrollDriver() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
||||||
if (data.signalMessageSent) {
|
if (data.signalMessageSent) {
|
||||||
toast.success('Driver enrolled! Setup instructions sent via Signal.');
|
toast.success('Driver enrolled! Setup instructions sent via Signal.');
|
||||||
} else {
|
} else {
|
||||||
@@ -163,10 +147,10 @@ export function useUnenrollDriver() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all });
|
||||||
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all });
|
||||||
toast.success('Driver unenrolled from GPS tracking');
|
toast.success('Driver unenrolled from GPS tracking');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -184,7 +168,7 @@ export function useUnenrollDriver() {
|
|||||||
*/
|
*/
|
||||||
export function useMyGpsStatus() {
|
export function useMyGpsStatus() {
|
||||||
return useQuery<MyGpsStatus>({
|
return useQuery<MyGpsStatus>({
|
||||||
queryKey: ['gps', 'me'],
|
queryKey: queryKeys.gps.me.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/me');
|
const { data } = await api.get('/gps/me');
|
||||||
return data;
|
return data;
|
||||||
@@ -197,7 +181,7 @@ export function useMyGpsStatus() {
|
|||||||
*/
|
*/
|
||||||
export function useMyGpsStats(from?: string, to?: string) {
|
export function useMyGpsStats(from?: string, to?: string) {
|
||||||
return useQuery<DriverStats>({
|
return useQuery<DriverStats>({
|
||||||
queryKey: ['gps', 'me', 'stats', from, to],
|
queryKey: queryKeys.gps.me.stats(from, to),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.append('from', from);
|
if (from) params.append('from', from);
|
||||||
@@ -213,7 +197,7 @@ export function useMyGpsStats(from?: string, to?: string) {
|
|||||||
*/
|
*/
|
||||||
export function useMyLocation() {
|
export function useMyLocation() {
|
||||||
return useQuery<DriverLocation>({
|
return useQuery<DriverLocation>({
|
||||||
queryKey: ['gps', 'me', 'location'],
|
queryKey: queryKeys.gps.me.location,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/me/location');
|
const { data } = await api.get('/gps/me/location');
|
||||||
return data;
|
return data;
|
||||||
@@ -234,7 +218,7 @@ export function useUpdateGpsConsent() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'me'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status });
|
||||||
toast.success(data.message);
|
toast.success(data.message);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -252,7 +236,7 @@ export function useUpdateGpsConsent() {
|
|||||||
*/
|
*/
|
||||||
export function useTraccarSetupStatus() {
|
export function useTraccarSetupStatus() {
|
||||||
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
|
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
|
||||||
queryKey: ['gps', 'traccar', 'status'],
|
queryKey: queryKeys.gps.traccar.status,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/traccar/status');
|
const { data } = await api.get('/gps/traccar/status');
|
||||||
return data;
|
return data;
|
||||||
@@ -272,8 +256,8 @@ export function useTraccarSetup() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status });
|
||||||
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.gps.status });
|
||||||
toast.success('Traccar setup complete!');
|
toast.success('Traccar setup complete!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -305,7 +289,7 @@ export function useSyncAdminsToTraccar() {
|
|||||||
*/
|
*/
|
||||||
export function useTraccarAdminUrl() {
|
export function useTraccarAdminUrl() {
|
||||||
return useQuery<{ url: string; directAccess: boolean }>({
|
return useQuery<{ url: string; directAccess: boolean }>({
|
||||||
queryKey: ['gps', 'traccar', 'admin-url'],
|
queryKey: queryKeys.gps.traccar.adminUrl,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/gps/traccar/admin-url');
|
const { data } = await api.get('/gps/traccar/admin-url');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
55
frontend/src/hooks/useListPage.ts
Normal file
55
frontend/src/hooks/useListPage.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useDebounce } from './useDebounce';
|
||||||
|
|
||||||
|
interface UseListPageOptions<T extends string> {
|
||||||
|
defaultSortKey: T;
|
||||||
|
defaultSortDirection?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListPage<T extends string>(options: UseListPageOptions<T>) {
|
||||||
|
const { defaultSortKey, defaultSortDirection = 'asc' } = options;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
|
||||||
|
// Sort state
|
||||||
|
const [sortKey, setSortKey] = useState<T>(defaultSortKey);
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection);
|
||||||
|
|
||||||
|
// Generic filter state (key-value pairs)
|
||||||
|
const [filters, setFiltersState] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const handleSort = (key: T) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFilter = (key: string, value: any) => {
|
||||||
|
setFiltersState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearch('');
|
||||||
|
setFiltersState({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
debouncedSearch,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
filters,
|
||||||
|
setFilter,
|
||||||
|
clearFilters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
||||||
|
import { queryKeys } from '../lib/query-keys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch PDF settings
|
* Fetch PDF settings
|
||||||
*/
|
*/
|
||||||
export function usePdfSettings() {
|
export function usePdfSettings() {
|
||||||
return useQuery<PdfSettings>({
|
return useQuery<PdfSettings>({
|
||||||
queryKey: ['settings', 'pdf'],
|
queryKey: queryKeys.settings.pdf,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/settings/pdf');
|
const { data } = await api.get('/settings/pdf');
|
||||||
return data;
|
return data;
|
||||||
@@ -27,7 +28,7 @@ export function useUpdatePdfSettings() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function useUploadLogo() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export function useDeleteLogo() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { queryKeys } from '../lib/query-keys';
|
||||||
|
|
||||||
export interface SignalMessage {
|
export interface SignalMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,7 +20,7 @@ export interface UnreadCounts {
|
|||||||
*/
|
*/
|
||||||
export function useDriverMessages(driverId: string | null, enabled = true) {
|
export function useDriverMessages(driverId: string | null, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-messages', driverId],
|
queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!driverId) return [];
|
if (!driverId) return [];
|
||||||
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
||||||
@@ -35,7 +36,7 @@ export function useDriverMessages(driverId: string | null, enabled = true) {
|
|||||||
*/
|
*/
|
||||||
export function useUnreadCounts() {
|
export function useUnreadCounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-unread-counts'],
|
queryKey: queryKeys.signal.unreadCounts,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
||||||
return data;
|
return data;
|
||||||
@@ -54,8 +55,10 @@ export function useDriverResponseCheck(
|
|||||||
// Only include events that have a driver
|
// Only include events that have a driver
|
||||||
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
||||||
|
|
||||||
|
const eventIds = eventsWithDrivers.map((e) => e.id).join(',');
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
|
queryKey: queryKeys.signal.driverResponses(eventIds),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (eventsWithDrivers.length === 0) {
|
if (eventsWithDrivers.length === 0) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
@@ -97,11 +100,11 @@ export function useSendMessage() {
|
|||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
// Add the new message to the cache immediately
|
// Add the new message to the cache immediately
|
||||||
queryClient.setQueryData<SignalMessage[]>(
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
['signal-messages', variables.driverId],
|
queryKeys.signal.messages(variables.driverId),
|
||||||
(old) => [...(old || []), data]
|
(old) => [...(old || []), data]
|
||||||
);
|
);
|
||||||
// Also invalidate to ensure consistency
|
// Also invalidate to ensure consistency
|
||||||
queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.signal.messages(variables.driverId) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,7 +123,7 @@ export function useMarkMessagesAsRead() {
|
|||||||
onSuccess: (_, driverId) => {
|
onSuccess: (_, driverId) => {
|
||||||
// Update the unread counts cache
|
// Update the unread counts cache
|
||||||
queryClient.setQueryData<UnreadCounts>(
|
queryClient.setQueryData<UnreadCounts>(
|
||||||
['signal-unread-counts'],
|
queryKeys.signal.unreadCounts,
|
||||||
(old) => {
|
(old) => {
|
||||||
if (!old) return {};
|
if (!old) return {};
|
||||||
const updated = { ...old };
|
const updated = { ...old };
|
||||||
@@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() {
|
|||||||
);
|
);
|
||||||
// Mark messages as read in the messages cache
|
// Mark messages as read in the messages cache
|
||||||
queryClient.setQueryData<SignalMessage[]>(
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
['signal-messages', driverId],
|
queryKeys.signal.messages(driverId),
|
||||||
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
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));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
export function formatDate(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleDateString('en-US', {
|
return d.toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(date: string | Date): string {
|
export function formatDateTime(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleString('en-US', {
|
return d.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -22,13 +23,30 @@ export function formatDateTime(date: string | Date): string {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(date: string | Date): string {
|
export function formatTime(date: string | Date, timeZone?: string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleTimeString('en-US', {
|
return d.toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
...(timeZone && { timeZone }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ISO datetime string to datetime-local input format (YYYY-MM-DDTHH:mm)
|
||||||
|
* Used for populating datetime-local inputs in forms
|
||||||
|
*/
|
||||||
|
export function toDatetimeLocal(isoString: string | null | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { handleError } from '@/lib/errorHandler';
|
import { handleError } from '@/lib/errorHandler';
|
||||||
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
import { PdfSettingsSection } from '@/components/PdfSettingsSection';
|
||||||
|
import { useTimezoneContext } from '@/contexts/TimezoneContext';
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Shield,
|
Shield,
|
||||||
|
Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@@ -51,9 +53,27 @@ interface MessageStats {
|
|||||||
driversWithMessages: number;
|
driversWithMessages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMON_TIMEZONES = [
|
||||||
|
{ value: 'America/New_York', label: 'Eastern (ET)' },
|
||||||
|
{ value: 'America/Chicago', label: 'Central (CT)' },
|
||||||
|
{ value: 'America/Denver', label: 'Mountain (MT)' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'Pacific (PT)' },
|
||||||
|
{ value: 'America/Anchorage', label: 'Alaska (AKT)' },
|
||||||
|
{ value: 'Pacific/Honolulu', label: 'Hawaii (HT)' },
|
||||||
|
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
|
||||||
|
{ value: 'UTC', label: 'UTC' },
|
||||||
|
{ value: 'Europe/London', label: 'London (GMT/BST)' },
|
||||||
|
{ value: 'Europe/Paris', label: 'Paris (CET/CEST)' },
|
||||||
|
{ value: 'Europe/Berlin', label: 'Berlin (CET/CEST)' },
|
||||||
|
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
|
||||||
|
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
|
||||||
|
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
|
||||||
|
];
|
||||||
|
|
||||||
export function AdminTools() {
|
export function AdminTools() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { timezone, setTimezone } = useTimezoneContext();
|
||||||
|
|
||||||
// Signal state
|
// Signal state
|
||||||
const [showQRCode, setShowQRCode] = useState(false);
|
const [showQRCode, setShowQRCode] = useState(false);
|
||||||
@@ -433,6 +453,33 @@ export function AdminTools() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* App Timezone */}
|
||||||
|
<div className="bg-card border border-border shadow-soft rounded-lg p-6 mb-8 transition-colors">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Globe className="h-5 w-5 text-blue-600 mr-2" />
|
||||||
|
<h2 className="text-lg font-medium text-foreground">App Timezone</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
All dates and times throughout the app will display in this timezone. Set this to match your event location.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary w-full max-w-xs"
|
||||||
|
>
|
||||||
|
{COMMON_TIMEZONES.map((tz) => (
|
||||||
|
<option key={tz.value} value={tz.value}>
|
||||||
|
{tz.label} ({tz.value})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Current: {new Date().toLocaleTimeString('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* PDF Customization Settings */}
|
{/* PDF Customization Settings */}
|
||||||
<PdfSettingsSection />
|
<PdfSettingsSection />
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { DriverLocationModal } from '@/components/DriverLocationModal';
|
|||||||
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts, useDriverResponseCheck } from '@/hooks/useSignalMessages';
|
||||||
import { useDriverLocations } from '@/hooks/useGps';
|
import { useDriverLocations } from '@/hooks/useGps';
|
||||||
import type { DriverLocation } from '@/types/gps';
|
import type { DriverLocation } from '@/types/gps';
|
||||||
|
import type { Flight } from '@/types';
|
||||||
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -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
|
const USER_INTERACTION_PAUSE = 20000; // pause 20 seconds after user interaction
|
||||||
|
|
||||||
export function CommandCenter() {
|
export function CommandCenter() {
|
||||||
|
const { formatTime, formatDateTime, timezone } = useFormattedDate();
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||||
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
||||||
@@ -193,7 +197,7 @@ export function CommandCenter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: flights } = useQuery<VIP['flights']>({
|
const { data: flights } = useQuery<Flight[]>({
|
||||||
queryKey: ['flights'],
|
queryKey: ['flights'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/flights');
|
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)
|
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
|
||||||
const now = currentTime;
|
const now = currentTime;
|
||||||
const awaitingConfirmation = (events || []).filter((event) => {
|
const awaitingConfirmation = (events || []).filter((event) => {
|
||||||
@@ -330,7 +340,7 @@ export function CommandCenter() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upcoming arrivals (next 4 hours) - use best available time (estimated > scheduled)
|
// 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;
|
flight.actualArrival || flight.estimatedArrival || flight.scheduledArrival;
|
||||||
|
|
||||||
const upcomingArrivals = vips
|
const upcomingArrivals = vips
|
||||||
@@ -442,7 +452,7 @@ export function CommandCenter() {
|
|||||||
const todayEnd = new Date(todayStart);
|
const todayEnd = new Date(todayStart);
|
||||||
todayEnd.setDate(todayEnd.getDate() + 1);
|
todayEnd.setDate(todayEnd.getDate() + 1);
|
||||||
|
|
||||||
flights.forEach((flight: any) => {
|
flights.forEach((flight) => {
|
||||||
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
const arrivalTime = flight.estimatedArrival || flight.scheduledArrival;
|
||||||
if (!arrivalTime) return;
|
if (!arrivalTime) return;
|
||||||
const arrDate = new Date(arrivalTime);
|
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
|
// Get time until event
|
||||||
function getTimeUntil(dateStr: string) {
|
function getTimeUntil(dateStr: string) {
|
||||||
const eventTime = new Date(dateStr);
|
const eventTime = new Date(dateStr);
|
||||||
@@ -560,10 +597,10 @@ export function CommandCenter() {
|
|||||||
{/* Live Clock */}
|
{/* Live Clock */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
|
<div className="text-4xl font-mono font-bold text-foreground tabular-nums">
|
||||||
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{currentTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: timezone })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
{currentTime.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timezone })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
|
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground mt-1">
|
||||||
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
|
<Radio className="h-3 w-3 text-green-500 animate-pulse" />
|
||||||
@@ -768,7 +805,7 @@ export function CommandCenter() {
|
|||||||
<div className="text-right flex-shrink-0">
|
<div className="text-right flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground">ETA</p>
|
<p className="text-xs text-muted-foreground">ETA</p>
|
||||||
<p className="text-lg font-bold text-green-600 dark:text-green-400">
|
<p className="text-lg font-bold text-green-600 dark:text-green-400">
|
||||||
{new Date(trip.endTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{formatTime(new Date(trip.endTime))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,7 +911,7 @@ export function CommandCenter() {
|
|||||||
{getTimeUntil(trip.startTime)}
|
{getTimeUntil(trip.startTime)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{new Date(trip.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{formatTime(new Date(trip.startTime))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -910,15 +947,19 @@ export function CommandCenter() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{upcomingArrivals.map((vip) => {
|
{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 flight = vip.flights.find(f => f.status?.toLowerCase() !== 'cancelled') || vip.flights[0];
|
||||||
const arrival = vip.expectedArrival || (flight && getFlightArrivalTime(flight));
|
const lastFlight = journey ? journey.flights[journey.flights.length - 1] : flight;
|
||||||
const delay = flight ? Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0) : 0;
|
const finalArrival = lastFlight ? (lastFlight.actualArrival || lastFlight.estimatedArrival || lastFlight.scheduledArrival) : null;
|
||||||
const flightStatus = flight?.status?.toLowerCase();
|
const arrival = vip.expectedArrival || finalArrival;
|
||||||
const isCancelled = flightStatus === 'cancelled';
|
const currentFlight = journey ? journey.flights[journey.currentSegmentIndex] : flight;
|
||||||
const isActive = flightStatus === 'active';
|
const delay = currentFlight ? Math.max(currentFlight.arrivalDelay || 0, currentFlight.departureDelay || 0) : 0;
|
||||||
const isLanded = flightStatus === 'landed' || !!flight?.actualArrival;
|
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
|
const timeColor = isCancelled
|
||||||
? 'text-red-600 dark:text-red-400'
|
? 'text-red-600 dark:text-red-400'
|
||||||
: isLanded
|
: isLanded
|
||||||
@@ -929,7 +970,9 @@ export function CommandCenter() {
|
|||||||
? 'text-purple-600 dark:text-purple-400'
|
? 'text-purple-600 dark:text-purple-400'
|
||||||
: 'text-blue-600 dark:text-blue-400';
|
: 'text-blue-600 dark:text-blue-400';
|
||||||
|
|
||||||
const borderColor = isCancelled
|
const borderColor = journey?.hasLayoverRisk
|
||||||
|
? 'border-l-orange-500'
|
||||||
|
: isCancelled
|
||||||
? 'border-l-red-500'
|
? 'border-l-red-500'
|
||||||
: delay > 30
|
: delay > 30
|
||||||
? 'border-l-amber-500'
|
? 'border-l-amber-500'
|
||||||
@@ -939,13 +982,24 @@ export function CommandCenter() {
|
|||||||
? 'border-l-emerald-500'
|
? 'border-l-emerald-500'
|
||||||
: 'border-l-blue-500';
|
: 'border-l-blue-500';
|
||||||
|
|
||||||
|
// Build route chain
|
||||||
|
const routeChain = journey && journey.isMultiSegment
|
||||||
|
? journey.flights.map(f => f.departureAirport).concat([journey.flights[journey.flights.length - 1].arrivalAirport]).join(' → ')
|
||||||
|
: flight ? `${flight.departureAirport} → ${flight.arrivalAirport}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
|
<div key={vip.id} className={`px-3 py-2 border-l-4 ${borderColor}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
|
<p className="font-medium text-foreground text-xs truncate">{vip.name}</p>
|
||||||
{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">
|
<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" />
|
<AlertTriangle className="w-2.5 h-2.5" />
|
||||||
+{delay}m
|
+{delay}m
|
||||||
@@ -958,18 +1012,16 @@ export function CommandCenter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
{flight && (
|
<span>{routeChain}</span>
|
||||||
<>
|
{journey?.isMultiSegment && (
|
||||||
<span className="font-medium">{flight.flightNumber}</span>
|
<span className="text-muted-foreground/60">{journey.flights.length} legs</span>
|
||||||
<span>{flight.departureAirport} → {flight.arrivalAirport}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 text-[10px] text-muted-foreground mt-0.5">
|
||||||
{flight.arrivalTerminal && <span>T{flight.arrivalTerminal}</span>}
|
{currentFlight.arrivalTerminal && <span>T{currentFlight.arrivalTerminal}</span>}
|
||||||
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
{flight.arrivalBaggage && <span>Bag {flight.arrivalBaggage}</span>}
|
{currentFlight.arrivalBaggage && <span>Bag {currentFlight.arrivalBaggage}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -979,7 +1031,7 @@ export function CommandCenter() {
|
|||||||
</p>
|
</p>
|
||||||
{arrival && !isCancelled && !isLanded && (
|
{arrival && !isCancelled && !isLanded && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{new Date(arrival).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
{formatTime(new Date(arrival))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Users, Car, Plane, Clock, AlertTriangle } from 'lucide-react';
|
import { Users, Car, Plane, Clock, AlertTriangle, Link2 } from 'lucide-react';
|
||||||
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
|
import { VIP, Driver, ScheduleEvent, Flight } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import { FlightProgressBar } from '@/components/FlightProgressBar';
|
import { FlightProgressBar } from '@/components/FlightProgressBar';
|
||||||
|
import { groupFlightsIntoJourneys, formatLayoverDuration } from '@/lib/journeyUtils';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
const { data: vips } = useQuery<VIP[]>({
|
const { data: vips } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -66,6 +69,25 @@ export function Dashboard() {
|
|||||||
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
.sort((a, b) => new Date(a.flightDate).getTime() - new Date(b.flightDate).getTime())
|
||||||
.slice(0, 5) || [];
|
.slice(0, 5) || [];
|
||||||
|
|
||||||
|
const journeys = useMemo(() => {
|
||||||
|
if (!flights || flights.length === 0) return [];
|
||||||
|
return groupFlightsIntoJourneys(flights);
|
||||||
|
}, [flights]);
|
||||||
|
|
||||||
|
const upcomingJourneys = useMemo(() => {
|
||||||
|
return journeys
|
||||||
|
.filter(j => j.effectiveStatus !== 'cancelled' && j.effectiveStatus !== 'landed')
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Active journeys first, then by ETA
|
||||||
|
if (a.effectiveStatus === 'active' && b.effectiveStatus !== 'active') return -1;
|
||||||
|
if (b.effectiveStatus === 'active' && a.effectiveStatus !== 'active') return 1;
|
||||||
|
const etaA = a.flights[a.currentSegmentIndex]?.estimatedArrival || a.flights[a.currentSegmentIndex]?.scheduledArrival || '';
|
||||||
|
const etaB = b.flights[b.currentSegmentIndex]?.estimatedArrival || b.flights[b.currentSegmentIndex]?.scheduledArrival || '';
|
||||||
|
return etaA.localeCompare(etaB);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [journeys]);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
name: 'Total VIPs',
|
name: 'Total VIPs',
|
||||||
@@ -184,18 +206,22 @@ export function Dashboard() {
|
|||||||
Flight Status
|
Flight Status
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Status summary */}
|
{/* Journey status summary */}
|
||||||
{flights && flights.length > 0 && (
|
{journeys.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-4 mb-4 pb-4 border-b border-border">
|
<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 inFlight = journeys.filter(j => j.effectiveStatus === 'active').length;
|
||||||
const delayed = flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length;
|
const connectionRisk = journeys.filter(j => j.hasLayoverRisk).length;
|
||||||
const cancelled = flights.filter(f => f.status?.toLowerCase() === 'cancelled').length;
|
const cancelled = journeys.filter(j => j.effectiveStatus === 'cancelled').length;
|
||||||
const landed = flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length;
|
const landed = journeys.filter(j => j.effectiveStatus === 'landed').length;
|
||||||
const scheduled = flights.filter(f => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(f.status?.toLowerCase() || '') && !f.actualArrival).length;
|
const scheduled = journeys.filter(j => !['active', 'landed', 'cancelled', 'diverted', 'incident'].includes(j.effectiveStatus)).length;
|
||||||
|
|
||||||
return (
|
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 && (
|
{inFlight > 0 && (
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
<span className="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 className="text-muted-foreground">in flight</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{delayed > 0 && (
|
{connectionRisk > 0 && (
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
<span className="flex items-center gap-1.5 text-sm">
|
||||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
<span className="font-medium text-amber-600 dark:text-amber-400">{delayed}</span>
|
<span className="font-medium text-orange-600 dark:text-orange-400">{connectionRisk}</span>
|
||||||
<span className="text-muted-foreground">delayed</span>
|
<span className="text-muted-foreground">at risk</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{cancelled > 0 && (
|
{cancelled > 0 && (
|
||||||
@@ -233,35 +259,43 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Arriving soon flights */}
|
{/* Upcoming journeys */}
|
||||||
{upcomingFlights.length > 0 ? (
|
{upcomingJourneys.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Arriving Soon
|
Active & Upcoming Journeys
|
||||||
</h3>
|
</h3>
|
||||||
{upcomingFlights.map((flight) => {
|
{upcomingJourneys.map((journey) => {
|
||||||
const delay = Math.max(flight.arrivalDelay || 0, flight.departureDelay || 0);
|
const currentFlight = journey.flights[journey.currentSegmentIndex];
|
||||||
const eta = flight.estimatedArrival || flight.scheduledArrival;
|
const lastFlight = journey.flights[journey.flights.length - 1];
|
||||||
const borderColor = delay > 30 ? 'border-amber-500' :
|
const delay = Math.max(currentFlight?.arrivalDelay || 0, currentFlight?.departureDelay || 0);
|
||||||
flight.status?.toLowerCase() === 'active' ? 'border-purple-500' :
|
const routeChain = journey.flights.map(f => f.departureAirport).concat([lastFlight.arrivalAirport]).join(' → ');
|
||||||
flight.status?.toLowerCase() === 'cancelled' ? 'border-red-500' :
|
|
||||||
|
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';
|
'border-indigo-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={flight.id}
|
key={journey.vipId}
|
||||||
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
|
className={`border-l-4 ${borderColor} pl-4 py-2 hover:bg-accent transition-colors rounded-r`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-foreground">{flight.flightNumber}</span>
|
<span className="text-sm font-medium text-foreground">{journey.vip?.name || 'Unknown'}</span>
|
||||||
{flight.airlineName && (
|
{journey.isMultiSegment && (
|
||||||
<span className="text-xs text-muted-foreground">{flight.airlineName}</span>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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">
|
<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" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
@@ -269,24 +303,51 @@ export function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
<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' :
|
journey.effectiveStatus === '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' :
|
journey.effectiveStatus === '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 === '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'
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
}`}>
|
}`}>
|
||||||
{flight.status || 'scheduled'}
|
{journey.effectiveStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Progress bar for current segment */}
|
||||||
{(flight.arrivalTerminal || flight.arrivalGate || flight.arrivalBaggage) && (
|
{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">
|
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||||
{flight.arrivalTerminal && <span>Terminal {flight.arrivalTerminal}</span>}
|
{currentFlight.arrivalTerminal && <span>Terminal {currentFlight.arrivalTerminal}</span>}
|
||||||
{flight.arrivalGate && <span>Gate {flight.arrivalGate}</span>}
|
{currentFlight.arrivalGate && <span>Gate {currentFlight.arrivalGate}</span>}
|
||||||
{flight.arrivalBaggage && <span>Baggage {flight.arrivalBaggage}</span>}
|
{currentFlight.arrivalBaggage && <span>Baggage {currentFlight.arrivalBaggage}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react';
|
import { Plus, Edit, Trash2, Search, X, Filter, Send, Eye } from 'lucide-react';
|
||||||
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
||||||
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
||||||
import { FilterModal } from '@/components/FilterModal';
|
import { FilterModal } from '@/components/FilterModal';
|
||||||
import { FilterChip } from '@/components/FilterChip';
|
import { FilterChip } from '@/components/FilterChip';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
import { SortableHeader } from '@/components/SortableHeader';
|
||||||
|
import { useListPage } from '@/hooks/useListPage';
|
||||||
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
||||||
import { DriverChatModal } from '@/components/DriverChatModal';
|
import { DriverChatModal } from '@/components/DriverChatModal';
|
||||||
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
||||||
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
||||||
|
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -20,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Search and filter state
|
// List page state management
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const {
|
||||||
|
search: searchTerm,
|
||||||
|
setSearch: setSearchTerm,
|
||||||
|
debouncedSearch: debouncedSearchTerm,
|
||||||
|
sortKey: sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
} = useListPage<'name' | 'phone' | 'department'>({
|
||||||
|
defaultSortKey: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter state
|
||||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -31,12 +45,8 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
// Schedule modal state
|
// Schedule modal state
|
||||||
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
|
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
|
||||||
|
|
||||||
// Sort state
|
// Confirm delete modal state
|
||||||
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
||||||
|
|
||||||
// Debounce search term
|
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
|
||||||
|
|
||||||
// Fetch unread message counts
|
// Fetch unread message counts
|
||||||
const { data: unreadCounts } = useUnreadCounts();
|
const { data: unreadCounts } = useUnreadCounts();
|
||||||
@@ -179,26 +189,12 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
setSelectedDepartments([]);
|
setSelectedDepartments([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: typeof sortColumn) => {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveDepartmentFilter = (dept: string) => {
|
const handleRemoveDepartmentFilter = (dept: string) => {
|
||||||
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilterLabel = (value: string) => {
|
const getFilterLabel = (value: string) => {
|
||||||
const labels = {
|
return DEPARTMENT_LABELS[value] || value;
|
||||||
'OFFICE_OF_DEVELOPMENT': 'Office of Development',
|
|
||||||
'ADMIN': 'Admin',
|
|
||||||
'OTHER': 'Other',
|
|
||||||
};
|
|
||||||
return labels[value as keyof typeof labels] || value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -212,8 +208,13 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, name: string) => {
|
const handleDelete = (id: string, name: string) => {
|
||||||
if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) {
|
setDeleteConfirm({ id, name });
|
||||||
deleteMutation.mutate(id);
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteMutation.mutate(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -354,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-muted/30">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
column="name"
|
||||||
onClick={() => handleSort('name')}
|
label="Name"
|
||||||
>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-2">
|
onSort={handleSort}
|
||||||
Name
|
/>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<SortableHeader
|
||||||
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
column="phone"
|
||||||
</div>
|
label="Phone"
|
||||||
</th>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<th
|
onSort={handleSort}
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
/>
|
||||||
onClick={() => handleSort('phone')}
|
<SortableHeader
|
||||||
>
|
column="department"
|
||||||
<div className="flex items-center gap-2">
|
label="Department"
|
||||||
Phone
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
onSort={handleSort}
|
||||||
{sortColumn === 'phone' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
/>
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
|
||||||
onClick={() => handleSort('department')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
Department
|
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
|
||||||
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Assigned Events
|
Assigned Events
|
||||||
</th>
|
</th>
|
||||||
@@ -539,11 +528,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
filterGroups={[
|
filterGroups={[
|
||||||
{
|
{
|
||||||
label: 'Department',
|
label: 'Department',
|
||||||
options: [
|
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
|
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
|
||||||
{ value: 'OTHER', label: 'Other' },
|
|
||||||
],
|
|
||||||
selectedValues: selectedDepartments,
|
selectedValues: selectedDepartments,
|
||||||
onToggle: handleDepartmentToggle,
|
onToggle: handleDepartmentToggle,
|
||||||
},
|
},
|
||||||
@@ -565,6 +550,17 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
|||||||
isOpen={!!scheduleDriver}
|
isOpen={!!scheduleDriver}
|
||||||
onClose={() => setScheduleDriver(null)}
|
onClose={() => setScheduleDriver(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Confirm Delete Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
title="Delete Driver"
|
||||||
|
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Phone,
|
Phone,
|
||||||
@@ -244,6 +245,7 @@ export function DriverProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GpsStatsSection() {
|
function GpsStatsSection() {
|
||||||
|
const { formatDate, formatDateTime } = useFormattedDate();
|
||||||
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
|
||||||
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
|
||||||
const updateConsent = useUpdateGpsConsent();
|
const updateConsent = useUpdateGpsConsent();
|
||||||
@@ -355,7 +357,7 @@ function GpsStatsSection() {
|
|||||||
) : gpsStats ? (
|
) : gpsStats ? (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Stats for the last 7 days ({new Date(gpsStats.period.from).toLocaleDateString()} - {new Date(gpsStats.period.to).toLocaleDateString()})
|
Stats for the last 7 days ({formatDate(gpsStats.period.from)} - {formatDate(gpsStats.period.to)})
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
@@ -386,7 +388,7 @@ function GpsStatsSection() {
|
|||||||
|
|
||||||
{gpsStats.stats.topSpeedTimestamp && (
|
{gpsStats.stats.topSpeedTimestamp && (
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Top speed recorded on {new Date(gpsStats.stats.topSpeedTimestamp).toLocaleString()}
|
Top speed recorded on {formatDateTime(gpsStats.stats.topSpeedTimestamp)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, EventType } from '@/types';
|
import { ScheduleEvent, EventType } from '@/types';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
||||||
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
import { EventForm, EventFormData } from '@/components/EventForm';
|
import { EventForm, EventFormData } from '@/components/EventForm';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
||||||
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
import { SortableHeader } from '@/components/SortableHeader';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
type ActivityFilter = 'ALL' | EventType;
|
type ActivityFilter = 'ALL' | EventType;
|
||||||
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips';
|
||||||
@@ -18,16 +21,22 @@ export function EventList() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { formatDateTime } = useFormattedDate();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
|
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Confirm delete modal state
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
// Sort state (inline instead of useListPage since search is not debounced here)
|
||||||
const [sortField, setSortField] = useState<SortField>('startTime');
|
const [sortField, setSortField] = useState<SortField>('startTime');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
||||||
queryKey: ['events'],
|
queryKey: queryKeys.events.all,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/events');
|
const { data } = await api.get('/events');
|
||||||
return data;
|
return data;
|
||||||
@@ -53,7 +62,7 @@ export function EventList() {
|
|||||||
await api.post('/events', data);
|
await api.post('/events', data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
toast.success('Event created successfully');
|
toast.success('Event created successfully');
|
||||||
@@ -70,7 +79,7 @@ export function EventList() {
|
|||||||
await api.patch(`/events/${id}`, data);
|
await api.patch(`/events/${id}`, data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -88,7 +97,7 @@ export function EventList() {
|
|||||||
await api.delete(`/events/${id}`);
|
await api.delete(`/events/${id}`);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
|
||||||
toast.success('Event deleted successfully');
|
toast.success('Event deleted successfully');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -108,8 +117,13 @@ export function EventList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, title: string) => {
|
const handleDelete = (id: string, title: string) => {
|
||||||
if (confirm(`Delete event "${title}"? This action cannot be undone.`)) {
|
setDeleteConfirm({ id, title });
|
||||||
deleteMutation.mutate(id);
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteMutation.mutate(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -311,77 +325,47 @@ export function EventList() {
|
|||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-muted/30">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
column="title"
|
||||||
onClick={() => handleSort('title')}
|
label="Title"
|
||||||
>
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-1">
|
onSort={handleSort}
|
||||||
Title
|
className="gap-1"
|
||||||
{sortField === 'title' ? (
|
/>
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
<SortableHeader
|
||||||
) : (
|
column="type"
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
label="Type"
|
||||||
)}
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
</div>
|
onSort={handleSort}
|
||||||
</th>
|
className="gap-1"
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
<SortableHeader
|
||||||
onClick={() => handleSort('type')}
|
column="vips"
|
||||||
>
|
label="VIPs"
|
||||||
<div className="flex items-center gap-1">
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
Type
|
onSort={handleSort}
|
||||||
{sortField === 'type' ? (
|
className="gap-1"
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
/>
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
|
||||||
onClick={() => handleSort('vips')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
VIPs
|
|
||||||
{sortField === 'vips' ? (
|
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Vehicle
|
Vehicle
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Driver
|
Driver
|
||||||
</th>
|
</th>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
column="startTime"
|
||||||
onClick={() => handleSort('startTime')}
|
label="Start Time"
|
||||||
>
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-1">
|
onSort={handleSort}
|
||||||
Start Time
|
className="gap-1"
|
||||||
{sortField === 'startTime' ? (
|
/>
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
<SortableHeader
|
||||||
) : (
|
column="status"
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
label="Status"
|
||||||
)}
|
currentSort={{ key: sortField, direction: sortDirection }}
|
||||||
</div>
|
onSort={handleSort}
|
||||||
</th>
|
className="gap-1"
|
||||||
<th
|
/>
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
|
||||||
onClick={() => handleSort('status')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
Status
|
|
||||||
{sortField === 'status' ? (
|
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -503,6 +487,17 @@ export function EventList() {
|
|||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Delete Modal */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleteConfirm(null)}
|
||||||
|
title="Delete Activity"
|
||||||
|
description={`Are you sure you want to delete "${deleteConfirm?.title}"? This action cannot be undone.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Link2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { FlightForm, FlightFormData } from '@/components/FlightForm';
|
import { FlightForm, FlightFormData } from '@/components/FlightForm';
|
||||||
import { FlightCard } from '@/components/FlightCard';
|
import { FlightCard } from '@/components/FlightCard';
|
||||||
@@ -22,95 +23,117 @@ import { FilterModal } from '@/components/FilterModal';
|
|||||||
import { FilterChip } from '@/components/FilterChip';
|
import { FilterChip } from '@/components/FilterChip';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { useFlights, useFlightBudget, useRefreshActiveFlights } from '@/hooks/useFlights';
|
import { 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;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof AlertTriangle;
|
icon: typeof AlertTriangle;
|
||||||
flights: Flight[];
|
journeys: Journey[];
|
||||||
color: string;
|
color: string;
|
||||||
defaultCollapsed?: boolean;
|
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 now = new Date();
|
||||||
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
const fourHoursFromNow = new Date(now.getTime() + 4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const groups: FlightGroup[] = [
|
const groups: JourneyGroup[] = [
|
||||||
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, flights: [], color: 'text-red-500' },
|
{ key: 'alerts', label: 'Alerts', icon: AlertTriangle, journeys: [], color: 'text-red-500' },
|
||||||
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, flights: [], color: 'text-purple-500' },
|
{ key: 'connection-risk', label: 'Connection Risk', icon: Link2, journeys: [], color: 'text-orange-500' },
|
||||||
{ key: 'active', label: 'In Flight', icon: Plane, flights: [], color: 'text-purple-500' },
|
{ key: 'arriving', label: 'Arriving Soon', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
{ key: 'departing', label: 'Departing Soon', icon: Clock, flights: [], color: 'text-blue-500' },
|
{ key: 'active', label: 'In Flight', icon: Plane, journeys: [], color: 'text-purple-500' },
|
||||||
{ key: 'scheduled', label: 'Scheduled', icon: Clock, flights: [], color: 'text-muted-foreground' },
|
{ key: 'departing', label: 'Departing Soon', icon: Clock, journeys: [], color: 'text-blue-500' },
|
||||||
{ key: 'completed', label: 'Completed', icon: Plane, flights: [], color: 'text-emerald-500', defaultCollapsed: true },
|
{ 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) {
|
for (const journey of journeys) {
|
||||||
const status = flight.status?.toLowerCase();
|
const status = journey.effectiveStatus;
|
||||||
const eta = flight.estimatedArrival || flight.scheduledArrival;
|
|
||||||
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
|
// 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
|
// Alerts: cancelled, diverted, incident
|
||||||
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
if (status === 'cancelled' || status === 'diverted' || status === 'incident') {
|
||||||
groups[0].flights.push(flight);
|
groups[0].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Completed: landed
|
// Completed: all segments landed
|
||||||
if (status === 'landed' || flight.actualArrival) {
|
if (status === 'landed') {
|
||||||
groups[5].flights.push(flight);
|
groups[6].journeys.push(journey);
|
||||||
continue;
|
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) {
|
if (status === 'active' && eta && new Date(eta) <= twoHoursFromNow) {
|
||||||
groups[1].flights.push(flight);
|
groups[2].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In flight: active
|
// In flight: active
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
groups[2].flights.push(flight);
|
groups[3].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Departing soon: departure within 4h
|
// Departing soon: next departure within 4h
|
||||||
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
if (departure && new Date(departure) <= fourHoursFromNow && new Date(departure) >= now) {
|
||||||
groups[3].flights.push(flight);
|
groups[4].journeys.push(journey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else is scheduled
|
// Everything else is scheduled
|
||||||
groups[4].flights.push(flight);
|
groups[5].journeys.push(journey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort within groups
|
// Sort within groups
|
||||||
groups[0].flights.sort((a, b) => (b.arrivalDelay || 0) - (a.arrivalDelay || 0)); // Worst first
|
groups[0].journeys.sort((a, b) => getJourneyMaxDelay(b) - getJourneyMaxDelay(a));
|
||||||
groups[1].flights.sort((a, b) => {
|
groups[1].journeys.sort((a, b) => {
|
||||||
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
// Worst risk first: missed > critical > warning
|
||||||
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
const riskOrder = { missed: 0, critical: 1, warning: 2, ok: 3, none: 4 };
|
||||||
return etaA.localeCompare(etaB);
|
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) => {
|
groups[2].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
const etaA = a.estimatedArrival || a.scheduledArrival || '';
|
groups[3].journeys.sort((a, b) => getJourneyEta(a).localeCompare(getJourneyEta(b)));
|
||||||
const etaB = b.estimatedArrival || b.scheduledArrival || '';
|
groups[4].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
return etaA.localeCompare(etaB);
|
groups[5].journeys.sort((a, b) => getJourneyDeparture(a).localeCompare(getJourneyDeparture(b)));
|
||||||
});
|
groups[6].journeys.sort((a, b) => {
|
||||||
groups[3].flights.sort((a, b) => {
|
const lastA = a.flights[a.flights.length - 1];
|
||||||
const depA = a.estimatedDeparture || a.scheduledDeparture || '';
|
const lastB = b.flights[b.flights.length - 1];
|
||||||
const depB = b.estimatedDeparture || b.scheduledDeparture || '';
|
const arrA = lastA.actualArrival || lastA.scheduledArrival || '';
|
||||||
return depA.localeCompare(depB);
|
const arrB = lastB.actualArrival || lastB.scheduledArrival || '';
|
||||||
});
|
|
||||||
groups[4].flights.sort((a, b) => {
|
|
||||||
const depA = a.scheduledDeparture || a.flightDate;
|
|
||||||
const depB = b.scheduledDeparture || b.flightDate;
|
|
||||||
return depA.localeCompare(depB);
|
|
||||||
});
|
|
||||||
groups[5].flights.sort((a, b) => {
|
|
||||||
const arrA = a.actualArrival || a.scheduledArrival || '';
|
|
||||||
const arrB = b.actualArrival || b.scheduledArrival || '';
|
|
||||||
return arrB.localeCompare(arrA); // Most recent first
|
return arrB.localeCompare(arrA); // Most recent first
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +227,7 @@ export function FlightList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter flights
|
// Filter flights, then group into journeys
|
||||||
const filteredFlights = useMemo(() => {
|
const filteredFlights = useMemo(() => {
|
||||||
if (!flights) return [];
|
if (!flights) return [];
|
||||||
|
|
||||||
@@ -223,7 +246,20 @@ export function FlightList() {
|
|||||||
});
|
});
|
||||||
}, [flights, debouncedSearchTerm, selectedStatuses]);
|
}, [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) => {
|
const toggleGroup = (key: string) => {
|
||||||
setCollapsedGroups(prev => {
|
setCollapsedGroups(prev => {
|
||||||
@@ -295,17 +331,23 @@ export function FlightList() {
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats
|
// Stats based on all journeys (not just filtered)
|
||||||
const stats = useMemo(() => {
|
const allJourneys = useMemo(() => {
|
||||||
if (!flights) return { active: 0, delayed: 0, onTime: 0, landed: 0 };
|
if (!flights) return [];
|
||||||
return {
|
return groupFlightsIntoJourneys(flights);
|
||||||
active: flights.filter(f => f.status?.toLowerCase() === 'active').length,
|
|
||||||
delayed: flights.filter(f => (f.arrivalDelay || f.departureDelay || 0) > 15).length,
|
|
||||||
onTime: flights.filter(f => f.status === 'scheduled' && !(f.departureDelay && f.departureDelay > 15)).length,
|
|
||||||
landed: flights.filter(f => f.status?.toLowerCase() === 'landed' || f.actualArrival).length,
|
|
||||||
};
|
|
||||||
}, [flights]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Flight Tracking</h1>
|
<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">
|
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
|
||||||
|
<span>{stats.total} journeys</span>
|
||||||
{stats.active > 0 && (
|
{stats.active > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-purple-500 animate-pulse" />
|
||||||
{stats.active} in flight
|
{stats.active} in flight
|
||||||
</span>
|
</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 && (
|
{stats.delayed > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||||
{stats.delayed} delayed
|
{stats.delayed} delayed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>{stats.onTime} scheduled</span>
|
|
||||||
<span>{stats.landed} landed</span>
|
<span>{stats.landed} landed</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -355,7 +403,7 @@ export function FlightList() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<BudgetIndicator />
|
<BudgetIndicator />
|
||||||
{flights && flights.length > 0 && (
|
{allJourneys.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => refreshActiveMutation.mutate()}
|
onClick={() => refreshActiveMutation.mutate()}
|
||||||
disabled={refreshActiveMutation.isPending}
|
disabled={refreshActiveMutation.isPending}
|
||||||
@@ -376,7 +424,7 @@ export function FlightList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
{flights && flights.length > 0 && (
|
{allJourneys.length > 0 && (
|
||||||
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
|
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1 relative">
|
<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="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights?.length || 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>
|
</div>
|
||||||
{(searchTerm || selectedStatuses.length > 0) && (
|
{(searchTerm || selectedStatuses.length > 0) && (
|
||||||
<button
|
<button
|
||||||
@@ -435,11 +483,11 @@ export function FlightList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flight Groups */}
|
{/* Journey Groups */}
|
||||||
{flights && flights.length > 0 ? (
|
{allJourneys.length > 0 ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{flightGroups.map((group) => {
|
{journeyGroups.map((group) => {
|
||||||
if (group.flights.length === 0) return null;
|
if (group.journeys.length === 0) return null;
|
||||||
const isCollapsed = collapsedGroups.has(group.key);
|
const isCollapsed = collapsedGroups.has(group.key);
|
||||||
const Icon = group.icon;
|
const Icon = group.icon;
|
||||||
|
|
||||||
@@ -460,18 +508,19 @@ export function FlightList() {
|
|||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground font-normal">
|
<span className="text-xs text-muted-foreground font-normal">
|
||||||
({group.flights.length})
|
({group.journeys.length})
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-border/50 ml-2" />
|
<div className="flex-1 border-t border-border/50 ml-2" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Flight cards */}
|
{/* Journey cards */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{group.flights.map((flight) => (
|
{group.journeys.map((journey) => (
|
||||||
<FlightCard
|
<FlightCard
|
||||||
key={flight.id}
|
key={journey.vipId}
|
||||||
flight={flight}
|
journey={journey.isMultiSegment ? journey : undefined}
|
||||||
|
flight={journey.isMultiSegment ? undefined : journey.flights[0]}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Navigation,
|
|
||||||
Battery,
|
|
||||||
Clock,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
X,
|
X,
|
||||||
Search,
|
Search,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
Gauge,
|
|
||||||
Activity,
|
Activity,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Route,
|
Clock,
|
||||||
Car,
|
|
||||||
Copy,
|
Copy,
|
||||||
QrCode,
|
QrCode,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -31,14 +22,13 @@ import {
|
|||||||
useGpsStatus,
|
useGpsStatus,
|
||||||
useGpsSettings,
|
useGpsSettings,
|
||||||
useUpdateGpsSettings,
|
useUpdateGpsSettings,
|
||||||
useDriverLocations,
|
|
||||||
useGpsDevices,
|
useGpsDevices,
|
||||||
useEnrollDriver,
|
useEnrollDriver,
|
||||||
useUnenrollDriver,
|
useUnenrollDriver,
|
||||||
useDriverStats,
|
|
||||||
useTraccarSetupStatus,
|
useTraccarSetupStatus,
|
||||||
useTraccarSetup,
|
useTraccarSetup,
|
||||||
useOpenTraccarAdmin,
|
useOpenTraccarAdmin,
|
||||||
|
useDeviceQr,
|
||||||
} from '@/hooks/useGps';
|
} from '@/hooks/useGps';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { ErrorMessage } from '@/components/ErrorMessage';
|
import { ErrorMessage } from '@/components/ErrorMessage';
|
||||||
@@ -46,73 +36,16 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import type { Driver } from '@/types';
|
import type { Driver } from '@/types';
|
||||||
import type { DriverLocation } from '@/types/gps';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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() {
|
export function GpsTracking() {
|
||||||
const { backendUser } = useAuth();
|
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 [showEnrollModal, setShowEnrollModal] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
|
|
||||||
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
||||||
|
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check admin access
|
// Check admin access
|
||||||
if (backendUser?.role !== 'ADMINISTRATOR') {
|
if (backendUser?.role !== 'ADMINISTRATOR') {
|
||||||
@@ -130,10 +63,9 @@ export function GpsTracking() {
|
|||||||
// Data hooks
|
// Data hooks
|
||||||
const { data: status, isLoading: statusLoading } = useGpsStatus();
|
const { data: status, isLoading: statusLoading } = useGpsStatus();
|
||||||
const { data: settings, isLoading: settingsLoading } = useGpsSettings();
|
const { data: settings, isLoading: settingsLoading } = useGpsSettings();
|
||||||
const { data: locations, isLoading: locationsLoading, refetch: refetchLocations } = useDriverLocations();
|
|
||||||
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
|
const { data: devices, isLoading: devicesLoading } = useGpsDevices();
|
||||||
const { data: traccarStatus } = useTraccarSetupStatus();
|
const { data: traccarStatus } = useTraccarSetupStatus();
|
||||||
const { data: driverStats } = useDriverStats(selectedDriverId);
|
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateSettings = useUpdateGpsSettings();
|
const updateSettings = useUpdateGpsSettings();
|
||||||
@@ -158,18 +90,6 @@ export function GpsTracking() {
|
|||||||
d.name.toLowerCase().includes(searchTerm.toLowerCase())
|
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) => {
|
const copyToClipboard = (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
toast.success('Copied to clipboard');
|
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 className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">GPS Tracking</h1>
|
<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>
|
||||||
<div className="flex gap-2">
|
<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 && (
|
{status?.traccarAvailable && (
|
||||||
<button
|
<button
|
||||||
onClick={() => openTraccar.mutate()}
|
onClick={() => openTraccar.mutate()}
|
||||||
@@ -248,7 +160,7 @@ export function GpsTracking() {
|
|||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-5 w-5 mr-2" />
|
<ExternalLink className="h-5 w-5 mr-2" />
|
||||||
Traccar Admin
|
Open Traccar Dashboard
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -267,6 +179,31 @@ export function GpsTracking() {
|
|||||||
</div>
|
</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 */}
|
{/* Status Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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">
|
<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="border-b border-border">
|
||||||
<div className="flex gap-1 -mb-px">
|
<div className="flex gap-1 -mb-px">
|
||||||
{[
|
{[
|
||||||
{ id: 'map', label: 'Live Map', icon: MapPin },
|
|
||||||
{ id: 'devices', label: 'Devices', icon: Smartphone },
|
{ id: 'devices', label: 'Devices', icon: Smartphone },
|
||||||
{ id: 'stats', label: 'Stats', icon: Gauge },
|
|
||||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button
|
<button
|
||||||
@@ -345,104 +280,6 @@ export function GpsTracking() {
|
|||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
|
||||||
{/* 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 */}
|
{/* Devices Tab */}
|
||||||
{activeTab === 'devices' && (
|
{activeTab === 'devices' && (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -491,7 +328,15 @@ export function GpsTracking() {
|
|||||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||||
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
|
{device.lastActive ? formatDistanceToNow(new Date(device.lastActive), { addSuffix: true }) : 'Never'}
|
||||||
</td>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
|
if (confirm(`Unenroll ${device.driver?.name} from GPS tracking?`)) {
|
||||||
@@ -520,74 +365,6 @@ export function GpsTracking() {
|
|||||||
</div>
|
</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 */}
|
{/* Settings Tab */}
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -603,18 +380,21 @@ export function GpsTracking() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={30}
|
min={10}
|
||||||
max={300}
|
max={300}
|
||||||
defaultValue={settings.updateIntervalSeconds}
|
defaultValue={settings.updateIntervalSeconds}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const value = parseInt(e.target.value);
|
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 });
|
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"
|
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>
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4">
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
@@ -788,7 +568,7 @@ export function GpsTracking() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -871,6 +651,129 @@ export function GpsTracking() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Plane } from 'lucide-react';
|
import { Plane, LogIn, Shield } from 'lucide-react';
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { isAuthenticated, loginWithRedirect } = useAuth();
|
const { isAuthenticated, loginWithRedirect } = useAuth();
|
||||||
@@ -14,30 +14,51 @@ export function Login() {
|
|||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
return (
|
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="min-h-screen flex items-center justify-center relative overflow-hidden bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950">
|
||||||
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-xl p-8">
|
{/* Background ambient effects */}
|
||||||
<div className="text-center mb-8">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div className="inline-block p-3 bg-primary/10 rounded-full mb-4">
|
<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" />
|
||||||
<Plane className="h-12 w-12 text-primary" />
|
<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>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-10 px-4 w-full max-w-sm">
|
||||||
|
{/* Breathing logo circle */}
|
||||||
|
<div className="animate-heartbeat">
|
||||||
|
<div className="relative w-36 h-36 rounded-full bg-gradient-to-br from-blue-500/20 to-indigo-600/20 backdrop-blur-sm border border-white/10 flex items-center justify-center animate-glow-pulse">
|
||||||
|
{/* Inner circle */}
|
||||||
|
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-500/30 to-indigo-600/30 border border-white/10 flex items-center justify-center shadow-2xl">
|
||||||
|
<Plane className="h-14 w-14 text-blue-400 drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-white tracking-tight mb-2">
|
||||||
VIP Coordinator
|
VIP Coordinator
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-blue-200/60 text-sm tracking-wide">
|
||||||
Transportation logistics and event coordination
|
Transportation logistics & event coordination
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sign in card */}
|
||||||
|
<div className="w-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => loginWithRedirect()}
|
onClick={() => loginWithRedirect()}
|
||||||
className="w-full bg-primary text-primary-foreground py-3 px-4 rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
className="w-full group relative flex items-center justify-center gap-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white py-4 px-6 rounded-2xl font-semibold text-lg shadow-lg shadow-blue-500/25 hover:shadow-blue-500/40 hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] transition-all duration-200"
|
||||||
>
|
>
|
||||||
Sign In with Auth0
|
<LogIn className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
{/* Footer note */}
|
||||||
<p>First user becomes administrator</p>
|
<div className="flex items-center gap-2 text-blue-300/40 text-xs">
|
||||||
<p>Subsequent users require admin approval</p>
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
<span>Authorized personnel only</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -44,6 +45,8 @@ interface DriverWithSchedule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MySchedule() {
|
export function MySchedule() {
|
||||||
|
const { formatDate, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
|
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
|
||||||
queryKey: ['my-driver-profile'],
|
queryKey: ['my-driver-profile'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -123,31 +126,6 @@ export function MySchedule() {
|
|||||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
|
||||||
.slice(0, 5); // Last 5 completed
|
.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) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'IN_PROGRESS':
|
case 'IN_PROGRESS':
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { AccountabilityRosterPDF } from '@/components/AccountabilityRosterPDF';
|
||||||
|
import { usePdfSettings } from '@/hooks/useSettings';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
@@ -13,6 +17,10 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Send,
|
||||||
|
MessageCircle,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface VIP {
|
interface VIP {
|
||||||
@@ -36,6 +44,10 @@ export function Reports() {
|
|||||||
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
|
const [activeReport, setActiveReport] = useState<ReportType>('accountability');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [departmentFilter, setDepartmentFilter] = useState<string>('all');
|
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[]>({
|
const { data: vips, isLoading } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
@@ -45,6 +57,8 @@ export function Reports() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: pdfSettings } = usePdfSettings();
|
||||||
|
|
||||||
const reports = [
|
const reports = [
|
||||||
{
|
{
|
||||||
id: 'accountability' as const,
|
id: 'accountability' as const,
|
||||||
@@ -52,9 +66,6 @@ export function Reports() {
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
description: 'Complete list of all personnel for emergency preparedness',
|
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
|
// Filter VIPs based on search and department
|
||||||
@@ -125,6 +136,90 @@ export function Reports() {
|
|||||||
link.click();
|
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) {
|
if (isLoading) {
|
||||||
return <Loading message="Loading report data..." />;
|
return <Loading message="Loading report data..." />;
|
||||||
}
|
}
|
||||||
@@ -237,13 +332,29 @@ export function Reports() {
|
|||||||
<option value="OTHER">Other</option>
|
<option value="OTHER">Other</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleExportCSV}
|
onClick={handleExportCSV}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Export CSV
|
CSV
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Active VIPs Table */}
|
{/* Active VIPs Table */}
|
||||||
@@ -452,6 +563,101 @@ export function Reports() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
|
import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,10 +20,21 @@ interface User {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfirmAction = 'approve' | 'delete' | 'changeRole';
|
||||||
|
|
||||||
export function UserList() {
|
export function UserList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { formatDate } = useFormattedDate();
|
||||||
const [processingUser, setProcessingUser] = useState<string | null>(null);
|
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[]>({
|
const { data: users, isLoading } = useQuery<User[]>({
|
||||||
queryKey: ['users'],
|
queryKey: ['users'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -91,23 +104,84 @@ export function UserList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRoleChange = (userId: string, newRole: string) => {
|
const handleRoleChange = (userId: string, userName: string, newRole: string) => {
|
||||||
if (confirm(`Change user role to ${newRole}?`)) {
|
setConfirmState({
|
||||||
changeRoleMutation.mutate({ userId, role: newRole });
|
action: 'changeRole',
|
||||||
}
|
userId,
|
||||||
|
userName,
|
||||||
|
newRole,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApprove = (userId: string) => {
|
const handleApprove = (userId: string, userName: string) => {
|
||||||
if (confirm('Approve this user?')) {
|
setConfirmState({
|
||||||
|
action: 'approve',
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeny = (userId: string, userName: string) => {
|
||||||
|
setConfirmState({
|
||||||
|
action: 'delete',
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!confirmState) return;
|
||||||
|
|
||||||
|
const { action, userId, newRole } = confirmState;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'approve':
|
||||||
setProcessingUser(userId);
|
setProcessingUser(userId);
|
||||||
approveMutation.mutate(userId);
|
approveMutation.mutate(userId);
|
||||||
}
|
break;
|
||||||
};
|
case 'delete':
|
||||||
|
|
||||||
const handleDeny = (userId: string) => {
|
|
||||||
if (confirm('Delete this user? This action cannot be undone.')) {
|
|
||||||
setProcessingUser(userId);
|
setProcessingUser(userId);
|
||||||
deleteUserMutation.mutate(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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{formatDate(user.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(user.id)}
|
onClick={() => handleApprove(user.id, user.name || user.email)}
|
||||||
disabled={processingUser === user.id}
|
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"
|
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
|
Approve
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeny(user.id)}
|
onClick={() => handleDeny(user.id, user.name || user.email)}
|
||||||
disabled={processingUser === user.id}
|
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"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={user.role}
|
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"
|
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>
|
<option value="DRIVER">Driver</option>
|
||||||
@@ -292,7 +366,7 @@ export function UserList() {
|
|||||||
<option value="ADMINISTRATOR">Administrator</option>
|
<option value="ADMINISTRATOR">Administrator</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<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"
|
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"
|
title="Delete user"
|
||||||
>
|
>
|
||||||
@@ -307,6 +381,16 @@ export function UserList() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
{confirmState && getConfirmModalProps() && (
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={true}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => setConfirmState(null)}
|
||||||
|
{...getConfirmModalProps()!}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { EventForm, EventFormData } from '@/components/EventForm';
|
|||||||
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||||
import { ScheduleEvent } from '@/types';
|
import { ScheduleEvent } from '@/types';
|
||||||
import { usePdfSettings } from '@/hooks/useSettings';
|
import { usePdfSettings } from '@/hooks/useSettings';
|
||||||
|
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -52,6 +53,7 @@ export function VIPSchedule() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { formatDate, formatDateTime, formatTime } = useFormattedDate();
|
||||||
|
|
||||||
// State for edit modal
|
// State for edit modal
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
@@ -169,12 +171,7 @@ export function VIPSchedule() {
|
|||||||
|
|
||||||
// Group events by day
|
// Group events by day
|
||||||
const eventsByDay = sortedEvents.reduce((acc, event) => {
|
const eventsByDay = sortedEvents.reduce((acc, event) => {
|
||||||
const date = new Date(event.startTime).toLocaleDateString('en-US', {
|
const date = formatDate(event.startTime);
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
if (!acc[date]) {
|
if (!acc[date]) {
|
||||||
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 () => {
|
const handleExport = async () => {
|
||||||
if (!vip) return;
|
if (!vip) return;
|
||||||
|
|
||||||
@@ -362,13 +352,7 @@ export function VIPSchedule() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
|
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">
|
||||||
{new Date(vip.expectedArrival).toLocaleString('en-US', {
|
{formatDateTime(vip.expectedArrival)}
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -399,12 +383,7 @@ export function VIPSchedule() {
|
|||||||
{flight.scheduledArrival && (
|
{flight.scheduledArrival && (
|
||||||
<p className="text-sm text-blue-900 dark:text-blue-100">
|
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
Arrives:{' '}
|
Arrives:{' '}
|
||||||
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
|
{formatDateTime(flight.scheduledArrival)}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{flight.status && (
|
{flight.status && (
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { VIP } from '@/types';
|
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 { VIPForm, VIPFormData } from '@/components/VIPForm';
|
||||||
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
|
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
|
||||||
import { FilterModal } from '@/components/FilterModal';
|
import { FilterModal } from '@/components/FilterModal';
|
||||||
import { FilterChip } from '@/components/FilterChip';
|
import { FilterChip } from '@/components/FilterChip';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||||
|
import { SortableHeader } from '@/components/SortableHeader';
|
||||||
|
import { useListPage } from '@/hooks/useListPage';
|
||||||
|
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
|
||||||
|
|
||||||
export function VIPList() {
|
export function VIPList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -18,21 +21,28 @@ export function VIPList() {
|
|||||||
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
|
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Search and filter state
|
// List page state management
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const {
|
||||||
|
search: searchTerm,
|
||||||
|
setSearch: setSearchTerm,
|
||||||
|
debouncedSearch: debouncedSearchTerm,
|
||||||
|
sortKey: sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort,
|
||||||
|
} = useListPage<'name' | 'organization' | 'department' | 'arrivalMode'>({
|
||||||
|
defaultSortKey: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter state
|
||||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||||
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
|
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
|
||||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
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)
|
// Roster-only toggle (hidden by default)
|
||||||
const [showRosterOnly, setShowRosterOnly] = useState(false);
|
const [showRosterOnly, setShowRosterOnly] = useState(false);
|
||||||
|
|
||||||
// Debounce search term for better performance
|
// Confirm delete modal state
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
const { data: vips, isLoading } = useQuery<VIP[]>({
|
const { data: vips, isLoading } = useQuery<VIP[]>({
|
||||||
queryKey: ['vips'],
|
queryKey: ['vips'],
|
||||||
@@ -170,15 +180,6 @@ export function VIPList() {
|
|||||||
setSelectedArrivalModes([]);
|
setSelectedArrivalModes([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column: typeof sortColumn) => {
|
|
||||||
if (sortColumn === column) {
|
|
||||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
||||||
} else {
|
|
||||||
setSortColumn(column);
|
|
||||||
setSortDirection('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveDepartmentFilter = (dept: string) => {
|
const handleRemoveDepartmentFilter = (dept: string) => {
|
||||||
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
||||||
};
|
};
|
||||||
@@ -188,18 +189,8 @@ export function VIPList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFilterLabel = (value: string, type: 'department' | 'arrivalMode') => {
|
const getFilterLabel = (value: string, type: 'department' | 'arrivalMode') => {
|
||||||
const labels = {
|
const labels = type === 'department' ? DEPARTMENT_LABELS : ARRIVAL_MODE_LABELS;
|
||||||
department: {
|
return labels[value] || value;
|
||||||
'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 handleAdd = () => {
|
const handleAdd = () => {
|
||||||
@@ -213,8 +204,13 @@ export function VIPList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string, name: string) => {
|
const handleDelete = (id: string, name: string) => {
|
||||||
if (confirm(`Delete VIP "${name}"? This action cannot be undone.`)) {
|
setDeleteConfirm({ id, name });
|
||||||
deleteMutation.mutate(id);
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
deleteMutation.mutate(deleteConfirm.id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -290,7 +286,7 @@ export function VIPList() {
|
|||||||
{/* Filter Button */}
|
{/* Filter Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterModalOpen(true)}
|
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' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Filter className="h-5 w-5 mr-2" />
|
<Filter className="h-5 w-5 mr-2" />
|
||||||
@@ -363,46 +359,30 @@ export function VIPList() {
|
|||||||
<table className="min-w-full divide-y divide-border">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-muted/30">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<SortableHeader
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
column="name"
|
||||||
onClick={() => handleSort('name')}
|
label="Name"
|
||||||
>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<div className="flex items-center gap-2">
|
onSort={handleSort}
|
||||||
Name
|
/>
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
<SortableHeader
|
||||||
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
column="organization"
|
||||||
</div>
|
label="Organization"
|
||||||
</th>
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<th
|
onSort={handleSort}
|
||||||
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')}
|
<SortableHeader
|
||||||
>
|
column="department"
|
||||||
<div className="flex items-center gap-2">
|
label="Department"
|
||||||
Organization
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
<ArrowUpDown className="h-4 w-4" />
|
onSort={handleSort}
|
||||||
{sortColumn === 'organization' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
/>
|
||||||
</div>
|
<SortableHeader
|
||||||
</th>
|
column="arrivalMode"
|
||||||
<th
|
label="Arrival Mode"
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||||
onClick={() => handleSort('department')}
|
onSort={handleSort}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -544,20 +524,13 @@ export function VIPList() {
|
|||||||
filterGroups={[
|
filterGroups={[
|
||||||
{
|
{
|
||||||
label: 'Department',
|
label: 'Department',
|
||||||
options: [
|
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
|
|
||||||
{ value: 'ADMIN', label: 'Admin' },
|
|
||||||
{ value: 'OTHER', label: 'Other' },
|
|
||||||
],
|
|
||||||
selectedValues: selectedDepartments,
|
selectedValues: selectedDepartments,
|
||||||
onToggle: handleDepartmentToggle,
|
onToggle: handleDepartmentToggle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Arrival Mode',
|
label: 'Arrival Mode',
|
||||||
options: [
|
options: Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => ({ value, label })),
|
||||||
{ value: 'FLIGHT', label: 'Flight' },
|
|
||||||
{ value: 'SELF_DRIVING', label: 'Self Driving' },
|
|
||||||
],
|
|
||||||
selectedValues: selectedArrivalModes,
|
selectedValues: selectedArrivalModes,
|
||||||
onToggle: handleArrivalModeToggle,
|
onToggle: handleArrivalModeToggle,
|
||||||
},
|
},
|
||||||
@@ -565,6 +538,17 @@ export function VIPList() {
|
|||||||
onClear={handleClearFilters}
|
onClear={handleClearFilters}
|
||||||
onApply={() => {}}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ export interface EnrollmentResponse {
|
|||||||
signalMessageSent?: boolean;
|
signalMessageSent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceQrInfo {
|
||||||
|
driverName: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
serverUrl: string;
|
||||||
|
qrCodeUrl: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MyGpsStatus {
|
export interface MyGpsStatus {
|
||||||
enrolled: boolean;
|
enrolled: boolean;
|
||||||
driverId?: string;
|
driverId?: string;
|
||||||
|
|||||||
@@ -210,3 +210,27 @@ export interface FlightBudget {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
month: string;
|
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: {
|
animation: {
|
||||||
'bounce-subtle': 'bounce-subtle 2s ease-in-out infinite',
|
'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: {
|
keyframes: {
|
||||||
'bounce-subtle': {
|
'bounce-subtle': {
|
||||||
'0%, 100%': { transform: 'translateY(0)' },
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
'50%': { transform: 'translateY(-2px)' },
|
'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