diff --git a/backend/src/common/pipes/index.ts b/backend/src/common/pipes/index.ts new file mode 100644 index 0000000..7edfed9 --- /dev/null +++ b/backend/src/common/pipes/index.ts @@ -0,0 +1 @@ +export * from './parse-boolean.pipe'; diff --git a/backend/src/common/pipes/parse-boolean.pipe.ts b/backend/src/common/pipes/parse-boolean.pipe.ts new file mode 100644 index 0000000..da9f6df --- /dev/null +++ b/backend/src/common/pipes/parse-boolean.pipe.ts @@ -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 { + 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`, + ); + } +} diff --git a/backend/src/common/utils/date.utils.ts b/backend/src/common/utils/date.utils.ts new file mode 100644 index 0000000..05fc66a --- /dev/null +++ b/backend/src/common/utils/date.utils.ts @@ -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>( + 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; +} diff --git a/backend/src/common/utils/hard-delete.utils.ts b/backend/src/common/utils/hard-delete.utils.ts new file mode 100644 index 0000000..5a7ea18 --- /dev/null +++ b/backend/src/common/utils/hard-delete.utils.ts @@ -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(options: { + id: string; + hardDelete: boolean; + userRole?: string; + findOne: (id: string) => Promise; + performHardDelete: (id: string) => Promise; + performSoftDelete: (id: string) => Promise; + entityName: string; + logger: Logger; +}): Promise { + 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); +} diff --git a/backend/src/common/utils/index.ts b/backend/src/common/utils/index.ts new file mode 100644 index 0000000..ae940c1 --- /dev/null +++ b/backend/src/common/utils/index.ts @@ -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'; diff --git a/backend/src/copilot/copilot.service.ts b/backend/src/copilot/copilot.service.ts index 596bbc0..350e2aa 100644 --- a/backend/src/copilot/copilot.service.ts +++ b/backend/src/copilot/copilot.service.ts @@ -3,6 +3,7 @@ import Anthropic from '@anthropic-ai/sdk'; import { PrismaService } from '../prisma/prisma.service'; import { MessagesService } from '../signal/messages.service'; import { ScheduleExportService } from '../drivers/schedule-export.service'; +import { toDateString, startOfDay } from '../common/utils/date.utils'; interface ChatMessage { role: 'user' | 'assistant'; @@ -609,7 +610,7 @@ export class CopilotService { } private buildSystemPrompt(userRole: string): string { - const today = new Date().toISOString().split('T')[0]; + const today = toDateString(new Date()); return `You are an AI administrative assistant for VIP Coordinator, a transportation and event logistics application. You help coordinators manage VIP guests, drivers, vehicles, and events. @@ -1174,8 +1175,7 @@ User role: ${userRole} } private async getTodaysSummary(): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); + const today = startOfDay(new Date()); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); @@ -1776,17 +1776,16 @@ User role: ${userRole} // Parse date or default to today const targetDate = date ? new Date(date) : new Date(); - const startOfDay = new Date(targetDate); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(targetDate); - endOfDay.setHours(23, 59, 59, 999); + const dayStart = startOfDay(targetDate); + const dayEnd = new Date(targetDate); + dayEnd.setHours(23, 59, 59, 999); // Get events for this day const events = await this.prisma.scheduleEvent.findMany({ where: { driverId: driver.id, deletedAt: null, - startTime: { gte: startOfDay, lte: endOfDay }, + startTime: { gte: dayStart, lte: dayEnd }, status: { not: 'CANCELLED' }, }, include: { @@ -1851,7 +1850,7 @@ User role: ${userRole} shiftStartTime: driver.shiftStartTime, shiftEndTime: driver.shiftEndTime, }, - date: targetDate.toISOString().split('T')[0], + date: toDateString(targetDate), eventCount: events.length, manifest, }, @@ -1933,16 +1932,15 @@ User role: ${userRole} const { date, driverNames } = input; const targetDate = new Date(date); - const startOfDay = new Date(targetDate); - startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(targetDate); - endOfDay.setHours(23, 59, 59, 999); + const dayStart = startOfDay(targetDate); + const dayEnd = new Date(targetDate); + dayEnd.setHours(23, 59, 59, 999); // Get all drivers with events on this date const eventsOnDate = await this.prisma.scheduleEvent.findMany({ where: { deletedAt: null, - startTime: { gte: startOfDay, lte: endOfDay }, + startTime: { gte: dayStart, lte: dayEnd }, status: { not: 'CANCELLED' }, driverId: { not: null }, }, @@ -1998,7 +1996,7 @@ User role: ${userRole} return { success: true, data: { - date: targetDate.toISOString().split('T')[0], + date: toDateString(targetDate), totalDrivers: targetDrivers.length, successful: results.length, failed: errors.length, @@ -2179,8 +2177,7 @@ User role: ${userRole} private async getWeeklyLookahead(input: Record): Promise { const { startDate, weeksAhead = 1 } = input; - const start = startDate ? new Date(startDate) : new Date(); - start.setHours(0, 0, 0, 0); + const start = startOfDay(startDate ? new Date(startDate) : new Date()); const end = new Date(start); end.setDate(end.getDate() + (weeksAhead * 7)); @@ -2227,9 +2224,8 @@ User role: ${userRole} const dayMap = new Map(); for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) { - const dateKey = d.toISOString().split('T')[0]; - const dayStart = new Date(d); - dayStart.setHours(0, 0, 0, 0); + const dateKey = toDateString(d); + const dayStart = startOfDay(d); const dayEnd = new Date(d); dayEnd.setHours(23, 59, 59, 999); @@ -2257,8 +2253,8 @@ User role: ${userRole} return { success: true, data: { - startDate: start.toISOString().split('T')[0], - endDate: end.toISOString().split('T')[0], + startDate: toDateString(start), + endDate: toDateString(end), weeksAhead, days: Array.from(dayMap.values()), summary: { @@ -2273,8 +2269,7 @@ User role: ${userRole} private async identifySchedulingGaps(input: Record): Promise { const { lookaheadDays = 7 } = input; - const start = new Date(); - start.setHours(0, 0, 0, 0); + const start = startOfDay(new Date()); const end = new Date(start); end.setDate(end.getDate() + lookaheadDays); @@ -2391,8 +2386,8 @@ User role: ${userRole} success: true, data: { dateRange: { - start: start.toISOString().split('T')[0], - end: end.toISOString().split('T')[0], + start: toDateString(start), + end: toDateString(end), }, totalEvents: events.length, issues: { @@ -2586,8 +2581,8 @@ User role: ${userRole} status: vehicle.status, }, dateRange: { - start: start.toISOString().split('T')[0], - end: end.toISOString().split('T')[0], + start: toDateString(start), + end: toDateString(end), }, eventCount: events.length, schedule: eventsWithDetails, @@ -2631,7 +2626,7 @@ User role: ${userRole} // Calculate total days in range const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); const daysWorked = new Set( - events.map(e => e.startTime.toISOString().split('T')[0]) + events.map(e => toDateString(e.startTime)) ).size; return { @@ -2655,8 +2650,8 @@ User role: ${userRole} success: true, data: { dateRange: { - start: start.toISOString().split('T')[0], - end: end.toISOString().split('T')[0], + start: toDateString(start), + end: toDateString(end), }, totalDrivers: drivers.length, workload: workloadSummary, @@ -2674,8 +2669,7 @@ User role: ${userRole} private async getCurrentSystemStatus(): Promise { const now = new Date(); - const today = new Date(now); - today.setHours(0, 0, 0, 0); + const today = startOfDay(now); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const nextWeek = new Date(today); diff --git a/backend/src/drivers/decorators/current-driver.decorator.ts b/backend/src/drivers/decorators/current-driver.decorator.ts new file mode 100644 index 0000000..5cb7095 --- /dev/null +++ b/backend/src/drivers/decorators/current-driver.decorator.ts @@ -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; + }, +); diff --git a/backend/src/drivers/decorators/index.ts b/backend/src/drivers/decorators/index.ts new file mode 100644 index 0000000..c4c3153 --- /dev/null +++ b/backend/src/drivers/decorators/index.ts @@ -0,0 +1 @@ +export * from './current-driver.decorator'; diff --git a/backend/src/drivers/drivers.controller.ts b/backend/src/drivers/drivers.controller.ts index 963bf8c..4c9d5cd 100644 --- a/backend/src/drivers/drivers.controller.ts +++ b/backend/src/drivers/drivers.controller.ts @@ -8,6 +8,7 @@ import { Param, Query, UseGuards, + UseInterceptors, NotFoundException, } from '@nestjs/common'; import { DriversService } from './drivers.service'; @@ -16,8 +17,12 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { CurrentDriver } from './decorators'; +import { ResolveDriverInterceptor } from './interceptors'; import { Role } from '@prisma/client'; import { CreateDriverDto, UpdateDriverDto } from './dto'; +import { toDateString } from '../common/utils/date.utils'; +import { ParseBooleanPipe } from '../common/pipes'; @Controller('drivers') @UseGuards(JwtAuthGuard, RolesGuard) @@ -41,11 +46,8 @@ export class DriversController { @Get('me') @Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR) - async getMyDriverProfile(@CurrentUser() user: any) { - const driver = await this.driversService.findByUserId(user.id); - if (!driver) { - throw new NotFoundException('Driver profile not found for current user'); - } + @UseInterceptors(ResolveDriverInterceptor) + getMyDriverProfile(@CurrentDriver() driver: any) { return driver; } @@ -55,22 +57,19 @@ export class DriversController { */ @Get('me/schedule/ics') @Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR) + @UseInterceptors(ResolveDriverInterceptor) async getMyScheduleICS( - @CurrentUser() user: any, + @CurrentDriver() driver: any, @Query('date') dateStr?: string, @Query('fullSchedule') fullScheduleStr?: string, ) { - const driver = await this.driversService.findByUserId(user.id); - if (!driver) { - throw new NotFoundException('Driver profile not found for current user'); - } const date = dateStr ? new Date(dateStr) : new Date(); // Default to full schedule (true) unless explicitly set to false const fullSchedule = fullScheduleStr !== 'false'; const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule); const filename = fullSchedule - ? `full-schedule-${new Date().toISOString().split('T')[0]}.ics` - : `schedule-${date.toISOString().split('T')[0]}.ics`; + ? `full-schedule-${toDateString(new Date())}.ics` + : `schedule-${toDateString(date)}.ics`; return { ics: icsContent, filename }; } @@ -80,22 +79,19 @@ export class DriversController { */ @Get('me/schedule/pdf') @Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR) + @UseInterceptors(ResolveDriverInterceptor) async getMySchedulePDF( - @CurrentUser() user: any, + @CurrentDriver() driver: any, @Query('date') dateStr?: string, @Query('fullSchedule') fullScheduleStr?: string, ) { - const driver = await this.driversService.findByUserId(user.id); - if (!driver) { - throw new NotFoundException('Driver profile not found for current user'); - } const date = dateStr ? new Date(dateStr) : new Date(); // Default to full schedule (true) unless explicitly set to false const fullSchedule = fullScheduleStr !== 'false'; const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule); const filename = fullSchedule - ? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf` - : `schedule-${date.toISOString().split('T')[0]}.pdf`; + ? `full-schedule-${toDateString(new Date())}.pdf` + : `schedule-${toDateString(date)}.pdf`; return { pdf: pdfBuffer.toString('base64'), filename }; } @@ -105,14 +101,11 @@ export class DriversController { */ @Post('me/send-schedule') @Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR) + @UseInterceptors(ResolveDriverInterceptor) async sendMySchedule( - @CurrentUser() user: any, + @CurrentDriver() driver: any, @Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean }, ) { - const driver = await this.driversService.findByUserId(user.id); - if (!driver) { - throw new NotFoundException('Driver profile not found for current user'); - } const date = body.date ? new Date(body.date) : new Date(); const format = body.format || 'both'; // Default to full schedule (true) unless explicitly set to false @@ -122,11 +115,8 @@ export class DriversController { @Patch('me') @Roles(Role.DRIVER) - async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) { - const driver = await this.driversService.findByUserId(user.id); - if (!driver) { - throw new NotFoundException('Driver profile not found for current user'); - } + @UseInterceptors(ResolveDriverInterceptor) + updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) { return this.driversService.update(driver.id, updateDriverDto); } @@ -219,10 +209,9 @@ export class DriversController { @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) remove( @Param('id') id: string, - @Query('hard') hard?: string, + @Query('hard', ParseBooleanPipe) hard: boolean, @CurrentUser() user?: any, ) { - const isHardDelete = hard === 'true'; - return this.driversService.remove(id, isHardDelete, user?.role); + return this.driversService.remove(id, hard, user?.role); } } diff --git a/backend/src/drivers/drivers.service.ts b/backend/src/drivers/drivers.service.ts index 29aca10..795eae3 100644 --- a/backend/src/drivers/drivers.service.ts +++ b/backend/src/drivers/drivers.service.ts @@ -1,11 +1,21 @@ -import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateDriverDto, UpdateDriverDto } from './dto'; +import { executeHardDelete } from '../common/utils'; @Injectable() export class DriversService { private readonly logger = new Logger(DriversService.name); + private readonly driverInclude = { + user: true, + events: { + where: { deletedAt: null }, + include: { vehicle: true, driver: true }, + orderBy: { startTime: 'asc' as const }, + }, + } as const; + constructor(private prisma: PrismaService) {} async create(createDriverDto: CreateDriverDto) { @@ -20,14 +30,7 @@ export class DriversService { async findAll() { return this.prisma.driver.findMany({ where: { deletedAt: null }, - include: { - user: true, - events: { - where: { deletedAt: null }, - include: { vehicle: true, driver: true }, - orderBy: { startTime: 'asc' }, - }, - }, + include: this.driverInclude, orderBy: { name: 'asc' }, }); } @@ -35,14 +38,7 @@ export class DriversService { async findOne(id: string) { const driver = await this.prisma.driver.findFirst({ where: { id, deletedAt: null }, - include: { - user: true, - events: { - where: { deletedAt: null }, - include: { vehicle: true, driver: true }, - orderBy: { startTime: 'asc' }, - }, - }, + include: this.driverInclude, }); if (!driver) { @@ -55,14 +51,7 @@ export class DriversService { async findByUserId(userId: string) { return this.prisma.driver.findFirst({ where: { userId, deletedAt: null }, - include: { - user: true, - events: { - where: { deletedAt: null }, - include: { vehicle: true, driver: true }, - orderBy: { startTime: 'asc' }, - }, - }, + include: this.driverInclude, }); } @@ -79,23 +68,19 @@ export class DriversService { } async remove(id: string, hardDelete = false, userRole?: string) { - if (hardDelete && userRole !== 'ADMINISTRATOR') { - throw new ForbiddenException('Only administrators can permanently delete records'); - } - - const driver = await this.findOne(id); - - if (hardDelete) { - this.logger.log(`Hard deleting driver: ${driver.name}`); - return this.prisma.driver.delete({ - where: { id: driver.id }, - }); - } - - this.logger.log(`Soft deleting driver: ${driver.name}`); - return this.prisma.driver.update({ - where: { id: driver.id }, - data: { deletedAt: new Date() }, + return executeHardDelete({ + id, + hardDelete, + userRole, + findOne: (id) => this.findOne(id), + performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }), + performSoftDelete: (id) => + this.prisma.driver.update({ + where: { id }, + data: { deletedAt: new Date() }, + }), + entityName: 'Driver', + logger: this.logger, }); } diff --git a/backend/src/drivers/interceptors/index.ts b/backend/src/drivers/interceptors/index.ts new file mode 100644 index 0000000..50d3a2a --- /dev/null +++ b/backend/src/drivers/interceptors/index.ts @@ -0,0 +1 @@ +export * from './resolve-driver.interceptor'; diff --git a/backend/src/drivers/interceptors/resolve-driver.interceptor.ts b/backend/src/drivers/interceptors/resolve-driver.interceptor.ts new file mode 100644 index 0000000..a18dce3 --- /dev/null +++ b/backend/src/drivers/interceptors/resolve-driver.interceptor.ts @@ -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> { + 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(); + } +} diff --git a/backend/src/drivers/schedule-export.service.ts b/backend/src/drivers/schedule-export.service.ts index 0d1b46d..5d081d5 100644 --- a/backend/src/drivers/schedule-export.service.ts +++ b/backend/src/drivers/schedule-export.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { SignalService } from '../signal/signal.service'; import * as ics from 'ics'; import * as PDFDocument from 'pdfkit'; +import { toDateString, startOfDay } from '../common/utils/date.utils'; interface ScheduleEventWithDetails { id: string; @@ -36,8 +37,7 @@ export class ScheduleExportService { driverId: string, date: Date, ): Promise { - const startOfDay = new Date(date); - startOfDay.setHours(0, 0, 0, 0); + const dayStart = startOfDay(date); const endOfDay = new Date(date); endOfDay.setHours(23, 59, 59, 999); @@ -47,7 +47,7 @@ export class ScheduleExportService { driverId, deletedAt: null, startTime: { - gte: startOfDay, + gte: dayStart, lte: endOfDay, }, status: { @@ -71,8 +71,7 @@ export class ScheduleExportService { async getDriverFullSchedule( driverId: string, ): Promise { - const now = new Date(); - now.setHours(0, 0, 0, 0); // Start of today + const now = startOfDay(new Date()); // Start of today const events = await this.prisma.scheduleEvent.findMany({ where: { @@ -411,8 +410,8 @@ export class ScheduleExportService { const icsContent = await this.generateICS(driverId, date, fullSchedule); const icsBase64 = Buffer.from(icsContent).toString('base64'); const filename = fullSchedule - ? `full-schedule-${new Date().toISOString().split('T')[0]}.ics` - : `schedule-${date.toISOString().split('T')[0]}.ics`; + ? `full-schedule-${toDateString(new Date())}.ics` + : `schedule-${toDateString(date)}.ics`; await this.signalService.sendMessageWithAttachment( fromNumber, @@ -435,8 +434,8 @@ export class ScheduleExportService { const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule); const pdfBase64 = pdfBuffer.toString('base64'); const filename = fullSchedule - ? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf` - : `schedule-${date.toISOString().split('T')[0]}.pdf`; + ? `full-schedule-${toDateString(new Date())}.pdf` + : `schedule-${toDateString(date)}.pdf`; await this.signalService.sendMessageWithAttachment( fromNumber, diff --git a/backend/src/events/events.controller.ts b/backend/src/events/events.controller.ts index 685e5e9..d5bb862 100644 --- a/backend/src/events/events.controller.ts +++ b/backend/src/events/events.controller.ts @@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Role } from '@prisma/client'; import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto'; +import { ParseBooleanPipe } from '../common/pipes'; @Controller('events') @UseGuards(JwtAuthGuard, RolesGuard) @@ -59,10 +60,9 @@ export class EventsController { @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) remove( @Param('id') id: string, - @Query('hard') hard?: string, + @Query('hard', ParseBooleanPipe) hard: boolean, @CurrentUser() user?: any, ) { - const isHardDelete = hard === 'true'; - return this.eventsService.remove(id, isHardDelete, user?.role); + return this.eventsService.remove(id, hard, user?.role); } } diff --git a/backend/src/events/events.service.ts b/backend/src/events/events.service.ts index daa0944..dd529b4 100644 --- a/backend/src/events/events.service.ts +++ b/backend/src/events/events.service.ts @@ -7,11 +7,24 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto'; +import { executeHardDelete } from '../common/utils'; @Injectable() export class EventsService { private readonly logger = new Logger(EventsService.name); + private readonly eventInclude = { + driver: true, + vehicle: true, + masterEvent: { + select: { id: true, title: true, type: true, startTime: true, endTime: true }, + }, + childEvents: { + where: { deletedAt: null }, + select: { id: true, title: true, type: true }, + }, + } as const; + constructor(private prisma: PrismaService) {} async create(createEventDto: CreateEventDto) { @@ -69,17 +82,7 @@ export class EventsService { startTime: new Date(createEventDto.startTime), endTime: new Date(createEventDto.endTime), }, - include: { - driver: true, - vehicle: true, - masterEvent: { - select: { id: true, title: true, type: true, startTime: true, endTime: true }, - }, - childEvents: { - where: { deletedAt: null }, - select: { id: true, title: true, type: true }, - }, - }, + include: this.eventInclude, }); return this.enrichEventWithVips(event); @@ -88,37 +91,46 @@ export class EventsService { async findAll() { const events = await this.prisma.scheduleEvent.findMany({ where: { deletedAt: null }, - include: { - driver: true, - vehicle: true, - masterEvent: { - select: { id: true, title: true, type: true, startTime: true, endTime: true }, - }, - childEvents: { - where: { deletedAt: null }, - select: { id: true, title: true, type: true }, - }, - }, + include: this.eventInclude, orderBy: { startTime: 'asc' }, }); - return Promise.all(events.map((event) => this.enrichEventWithVips(event))); + // Collect all unique VIP IDs from all events + const allVipIds = new Set(); + 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) }, + deletedAt: null, + }, + }); + vips.forEach((vip) => vipsMap.set(vip.id, vip)); + } + + // Enrich each event with its VIPs from the map (no additional queries) + return events.map((event) => { + if (!event.vipIds || event.vipIds.length === 0) { + return { ...event, vips: [], vip: null }; + } + + const vips = event.vipIds + .map((vipId) => vipsMap.get(vipId)) + .filter((vip) => vip !== undefined); + + return { ...event, vips, vip: vips[0] || null }; + }); } async findOne(id: string) { const event = await this.prisma.scheduleEvent.findFirst({ where: { id, deletedAt: null }, - include: { - driver: true, - vehicle: true, - masterEvent: { - select: { id: true, title: true, type: true, startTime: true, endTime: true }, - }, - childEvents: { - where: { deletedAt: null }, - select: { id: true, title: true, type: true }, - }, - }, + include: this.eventInclude, }); if (!event) { @@ -207,17 +219,7 @@ export class EventsService { const updatedEvent = await this.prisma.scheduleEvent.update({ where: { id: event.id }, data: updateData, - include: { - driver: true, - vehicle: true, - masterEvent: { - select: { id: true, title: true, type: true, startTime: true, endTime: true }, - }, - childEvents: { - where: { deletedAt: null }, - select: { id: true, title: true, type: true }, - }, - }, + include: this.eventInclude, }); return this.enrichEventWithVips(updatedEvent); @@ -233,40 +235,27 @@ export class EventsService { const updatedEvent = await this.prisma.scheduleEvent.update({ where: { id: event.id }, data: { status: updateEventStatusDto.status }, - include: { - driver: true, - vehicle: true, - masterEvent: { - select: { id: true, title: true, type: true, startTime: true, endTime: true }, - }, - childEvents: { - where: { deletedAt: null }, - select: { id: true, title: true, type: true }, - }, - }, + include: this.eventInclude, }); return this.enrichEventWithVips(updatedEvent); } async remove(id: string, hardDelete = false, userRole?: string) { - if (hardDelete && userRole !== 'ADMINISTRATOR') { - throw new ForbiddenException('Only administrators can permanently delete records'); - } - - const event = await this.findOne(id); - - if (hardDelete) { - this.logger.log(`Hard deleting event: ${event.title}`); - return this.prisma.scheduleEvent.delete({ - where: { id: event.id }, - }); - } - - this.logger.log(`Soft deleting event: ${event.title}`); - return this.prisma.scheduleEvent.update({ - where: { id: event.id }, - data: { deletedAt: new Date() }, + return executeHardDelete({ + id, + hardDelete, + userRole, + findOne: (id) => this.findOne(id), + performHardDelete: (id) => + this.prisma.scheduleEvent.delete({ where: { id } }), + performSoftDelete: (id) => + this.prisma.scheduleEvent.update({ + where: { id }, + data: { deletedAt: new Date() }, + }), + entityName: 'Event', + logger: this.logger, }); } diff --git a/backend/src/flights/flight-tracking.service.ts b/backend/src/flights/flight-tracking.service.ts index 95f2475..9a114cf 100644 --- a/backend/src/flights/flight-tracking.service.ts +++ b/backend/src/flights/flight-tracking.service.ts @@ -5,6 +5,7 @@ import { Cron } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { firstValueFrom } from 'rxjs'; import { Flight } from '@prisma/client'; +import { toDateString } from '../common/utils/date.utils'; // Tracking phases - determines polling priority const PHASE = { @@ -319,7 +320,7 @@ export class FlightTrackingService { private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise { const flightDate = flight.flightDate - ? new Date(flight.flightDate).toISOString().split('T')[0] + ? toDateString(new Date(flight.flightDate)) : undefined; try { diff --git a/backend/src/flights/flights.controller.ts b/backend/src/flights/flights.controller.ts index 3b3d525..07e8c10 100644 --- a/backend/src/flights/flights.controller.ts +++ b/backend/src/flights/flights.controller.ts @@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { Role } from '@prisma/client'; import { CreateFlightDto, UpdateFlightDto } from './dto'; +import { ParseBooleanPipe } from '../common/pipes'; @Controller('flights') @UseGuards(JwtAuthGuard, RolesGuard) @@ -88,9 +89,8 @@ export class FlightsController { @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) remove( @Param('id') id: string, - @Query('hard') hard?: string, + @Query('hard', ParseBooleanPipe) hard: boolean, ) { - const isHardDelete = hard === 'true'; - return this.flightsService.remove(id, isHardDelete); + return this.flightsService.remove(id, hard); } } diff --git a/backend/src/flights/flights.service.ts b/backend/src/flights/flights.service.ts index c96e466..d9ad6c3 100644 --- a/backend/src/flights/flights.service.ts +++ b/backend/src/flights/flights.service.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { CreateFlightDto, UpdateFlightDto } from './dto'; import { firstValueFrom } from 'rxjs'; +import { convertOptionalDates } from '../common/utils/date.utils'; @Injectable() export class FlightsService { @@ -24,17 +25,16 @@ export class FlightsService { `Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`, ); - return this.prisma.flight.create({ - data: { + const data = convertOptionalDates( + { ...createFlightDto, flightDate: new Date(createFlightDto.flightDate), - scheduledDeparture: createFlightDto.scheduledDeparture - ? new Date(createFlightDto.scheduledDeparture) - : undefined, - scheduledArrival: createFlightDto.scheduledArrival - ? new Date(createFlightDto.scheduledArrival) - : undefined, }, + ['scheduledDeparture', 'scheduledArrival'], + ); + + return this.prisma.flight.create({ + data, include: { vip: true }, }); } @@ -71,24 +71,13 @@ export class FlightsService { this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`); - const updateData: any = { ...updateFlightDto }; - const dto = updateFlightDto as any; // Type assertion to work around PartialType - - if (dto.flightDate) { - updateData.flightDate = new Date(dto.flightDate); - } - if (dto.scheduledDeparture) { - updateData.scheduledDeparture = new Date(dto.scheduledDeparture); - } - if (dto.scheduledArrival) { - updateData.scheduledArrival = new Date(dto.scheduledArrival); - } - if (dto.actualDeparture) { - updateData.actualDeparture = new Date(dto.actualDeparture); - } - if (dto.actualArrival) { - updateData.actualArrival = new Date(dto.actualArrival); - } + const updateData = convertOptionalDates(updateFlightDto, [ + 'flightDate', + 'scheduledDeparture', + 'scheduledArrival', + 'actualDeparture', + 'actualArrival', + ]); return this.prisma.flight.update({ where: { id: flight.id }, diff --git a/backend/src/signal/messages.controller.ts b/backend/src/signal/messages.controller.ts index 0f2ed5e..83bb0ba 100644 --- a/backend/src/signal/messages.controller.ts +++ b/backend/src/signal/messages.controller.ts @@ -17,6 +17,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { Public } from '../auth/decorators/public.decorator'; import { MessagesService, SendMessageDto } from './messages.service'; +import { toDateString } from '../common/utils/date.utils'; // DTO for incoming Signal webhook interface SignalWebhookPayload { @@ -154,7 +155,7 @@ export class MessagesController { async exportMessages(@Res() res: Response) { const exportData = await this.messagesService.exportAllMessages(); - const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`; + const filename = `signal-chats-${toDateString(new Date())}.txt`; res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); diff --git a/backend/src/vehicles/vehicles.controller.ts b/backend/src/vehicles/vehicles.controller.ts index 96695a3..3e89c86 100644 --- a/backend/src/vehicles/vehicles.controller.ts +++ b/backend/src/vehicles/vehicles.controller.ts @@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Role } from '@prisma/client'; import { CreateVehicleDto, UpdateVehicleDto } from './dto'; +import { ParseBooleanPipe } from '../common/pipes'; @Controller('vehicles') @UseGuards(JwtAuthGuard, RolesGuard) @@ -59,10 +60,9 @@ export class VehiclesController { @Roles(Role.ADMINISTRATOR, Role.COORDINATOR) remove( @Param('id') id: string, - @Query('hard') hard?: string, + @Query('hard', ParseBooleanPipe) hard: boolean, @CurrentUser() user?: any, ) { - const isHardDelete = hard === 'true'; - return this.vehiclesService.remove(id, isHardDelete, user?.role); + return this.vehiclesService.remove(id, hard, user?.role); } } diff --git a/backend/src/vehicles/vehicles.service.ts b/backend/src/vehicles/vehicles.service.ts index 1bab53b..8d7c929 100644 --- a/backend/src/vehicles/vehicles.service.ts +++ b/backend/src/vehicles/vehicles.service.ts @@ -1,11 +1,21 @@ -import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateVehicleDto, UpdateVehicleDto } from './dto'; +import { executeHardDelete } from '../common/utils'; @Injectable() export class VehiclesService { private readonly logger = new Logger(VehiclesService.name); + private readonly vehicleInclude = { + currentDriver: true, + events: { + where: { deletedAt: null }, + include: { driver: true, vehicle: true }, + orderBy: { startTime: 'asc' as const }, + }, + } as const; + constructor(private prisma: PrismaService) {} async create(createVehicleDto: CreateVehicleDto) { @@ -13,27 +23,14 @@ export class VehiclesService { return this.prisma.vehicle.create({ data: createVehicleDto, - include: { - currentDriver: true, - events: { - where: { deletedAt: null }, - include: { driver: true, vehicle: true }, - }, - }, + include: this.vehicleInclude, }); } async findAll() { return this.prisma.vehicle.findMany({ where: { deletedAt: null }, - include: { - currentDriver: true, - events: { - where: { deletedAt: null }, - include: { driver: true, vehicle: true }, - orderBy: { startTime: 'asc' }, - }, - }, + include: this.vehicleInclude, orderBy: { name: 'asc' }, }); } @@ -54,14 +51,7 @@ export class VehiclesService { async findOne(id: string) { const vehicle = await this.prisma.vehicle.findFirst({ where: { id, deletedAt: null }, - include: { - currentDriver: true, - events: { - where: { deletedAt: null }, - include: { driver: true, vehicle: true }, - orderBy: { startTime: 'asc' }, - }, - }, + include: this.vehicleInclude, }); if (!vehicle) { @@ -79,34 +69,24 @@ export class VehiclesService { return this.prisma.vehicle.update({ where: { id: vehicle.id }, data: updateVehicleDto, - include: { - currentDriver: true, - events: { - where: { deletedAt: null }, - include: { driver: true, vehicle: true }, - }, - }, + include: this.vehicleInclude, }); } async remove(id: string, hardDelete = false, userRole?: string) { - if (hardDelete && userRole !== 'ADMINISTRATOR') { - throw new ForbiddenException('Only administrators can permanently delete records'); - } - - const vehicle = await this.findOne(id); - - if (hardDelete) { - this.logger.log(`Hard deleting vehicle: ${vehicle.name}`); - return this.prisma.vehicle.delete({ - where: { id: vehicle.id }, - }); - } - - this.logger.log(`Soft deleting vehicle: ${vehicle.name}`); - return this.prisma.vehicle.update({ - where: { id: vehicle.id }, - data: { deletedAt: new Date() }, + return executeHardDelete({ + id, + hardDelete, + userRole, + findOne: (id) => this.findOne(id), + performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }), + performSoftDelete: (id) => + this.prisma.vehicle.update({ + where: { id }, + data: { deletedAt: new Date() }, + }), + entityName: 'Vehicle', + logger: this.logger, }); } @@ -114,24 +94,35 @@ export class VehiclesService { * Get vehicle utilization statistics */ async getUtilization() { - const vehicles = await this.findAll(); + const now = new Date(); - const stats = vehicles.map((vehicle) => { - const upcomingEvents = vehicle.events.filter( - (event) => new Date(event.startTime) > new Date(), - ); - - return { - id: vehicle.id, - name: vehicle.name, - type: vehicle.type, - seatCapacity: vehicle.seatCapacity, - status: vehicle.status, - upcomingTrips: upcomingEvents.length, - currentDriver: vehicle.currentDriver?.name, - }; + // Fetch vehicles with only upcoming events (filtered at database level) + const vehicles = await this.prisma.vehicle.findMany({ + where: { deletedAt: null }, + include: { + currentDriver: true, + events: { + where: { + deletedAt: null, + startTime: { gt: now }, // Only fetch upcoming events + }, + include: { driver: true, vehicle: true }, + orderBy: { startTime: 'asc' }, + }, + }, + orderBy: { name: 'asc' }, }); + const stats = vehicles.map((vehicle) => ({ + id: vehicle.id, + name: vehicle.name, + type: vehicle.type, + seatCapacity: vehicle.seatCapacity, + status: vehicle.status, + upcomingTrips: vehicle.events.length, // Already filtered at DB level + currentDriver: vehicle.currentDriver?.name, + })); + return { totalVehicles: vehicles.length, available: vehicles.filter((v) => v.status === 'AVAILABLE').length, diff --git a/backend/src/vips/vips.controller.ts b/backend/src/vips/vips.controller.ts index 7e8e492..bbcafde 100644 --- a/backend/src/vips/vips.controller.ts +++ b/backend/src/vips/vips.controller.ts @@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard'; import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CreateVipDto, UpdateVipDto } from './dto'; +import { ParseBooleanPipe } from '../common/pipes'; @Controller('vips') @UseGuards(JwtAuthGuard, AbilitiesGuard) @@ -49,11 +50,9 @@ export class VipsController { @CanDelete('VIP') remove( @Param('id') id: string, - @Query('hard') hard?: string, + @Query('hard', ParseBooleanPipe) hard: boolean, @CurrentUser() user?: any, ) { - // Only administrators can hard delete - const isHardDelete = hard === 'true'; - return this.vipsService.remove(id, isHardDelete, user?.role); + return this.vipsService.remove(id, hard, user?.role); } } diff --git a/backend/src/vips/vips.service.ts b/backend/src/vips/vips.service.ts index f680dab..363607a 100644 --- a/backend/src/vips/vips.service.ts +++ b/backend/src/vips/vips.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateVipDto, UpdateVipDto } from './dto'; +import { executeHardDelete } from '../common/utils'; @Injectable() export class VipsService { @@ -59,23 +60,19 @@ export class VipsService { } async remove(id: string, hardDelete = false, userRole?: string) { - if (hardDelete && userRole !== 'ADMINISTRATOR') { - throw new ForbiddenException('Only administrators can permanently delete records'); - } - - const vip = await this.findOne(id); - - if (hardDelete) { - this.logger.log(`Hard deleting VIP: ${vip.name}`); - return this.prisma.vIP.delete({ - where: { id: vip.id }, - }); - } - - this.logger.log(`Soft deleting VIP: ${vip.name}`); - return this.prisma.vIP.update({ - where: { id: vip.id }, - data: { deletedAt: new Date() }, + return executeHardDelete({ + id, + hardDelete, + userRole, + findOne: (id) => this.findOne(id), + performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }), + performSoftDelete: (id) => + this.prisma.vIP.update({ + where: { id }, + data: { deletedAt: new Date() }, + }), + entityName: 'VIP', + logger: this.logger, }); } } diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..ceb7ed0 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* Header with icon */} +
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/DriverForm.tsx b/frontend/src/components/DriverForm.tsx index 3fd2786..47c8847 100644 --- a/frontend/src/components/DriverForm.tsx +++ b/frontend/src/components/DriverForm.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { X } from 'lucide-react'; +import { DEPARTMENT_LABELS } from '@/lib/enum-labels'; interface DriverFormProps { driver?: Driver | null; @@ -112,9 +113,11 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" > - - - + {Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ( + + ))} diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 18ed191..10e00a9 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useQuery } from '@tanstack/react-query'; import { X, AlertTriangle, Users, Car, Link2 } from 'lucide-react'; import { api } from '@/lib/api'; import { ScheduleEvent, VIP, Driver, Vehicle } from '@/types'; import { useFormattedDate } from '@/hooks/useFormattedDate'; +import { toDatetimeLocal } from '@/lib/utils'; +import { EVENT_TYPE_LABELS, EVENT_STATUS_LABELS } from '@/lib/enum-labels'; +import { queryKeys } from '@/lib/query-keys'; interface EventFormProps { event?: ScheduleEvent | null; @@ -41,18 +44,6 @@ interface ScheduleConflict { export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) { const { formatDateTime } = useFormattedDate(); - // Helper to convert ISO datetime to datetime-local format - const toDatetimeLocal = (isoString: string | null | undefined) => { - if (!isoString) return ''; - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - const [formData, setFormData] = useState({ vipIds: event?.vipIds || [], title: event?.title || '', @@ -77,7 +68,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction // Fetch VIPs for selection const { data: vips } = useQuery({ - queryKey: ['vips'], + queryKey: queryKeys.vips.all, queryFn: async () => { const { data } = await api.get('/vips'); return data; @@ -86,7 +77,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction // Fetch Drivers for dropdown const { data: drivers } = useQuery({ - queryKey: ['drivers'], + queryKey: queryKeys.drivers.all, queryFn: async () => { const { data } = await api.get('/drivers'); return data; @@ -95,7 +86,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction // Fetch Vehicles for dropdown const { data: vehicles } = useQuery({ - queryKey: ['vehicles'], + queryKey: queryKeys.vehicles.all, queryFn: async () => { const { data } = await api.get('/vehicles'); return data; @@ -104,7 +95,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction // Fetch all events (for master event selector) const { data: allEvents } = useQuery({ - queryKey: ['events'], + queryKey: queryKeys.events.all, queryFn: async () => { const { data } = await api.get('/events'); return data; @@ -219,10 +210,12 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction }); }; - const selectedVipNames = vips - ?.filter(vip => formData.vipIds.includes(vip.id)) - .map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name) - .join(', ') || 'None selected'; + const selectedVipNames = useMemo(() => { + return vips + ?.filter(vip => formData.vipIds.includes(vip.id)) + .map(vip => vip.partySize > 1 ? `${vip.name} (+${vip.partySize - 1})` : vip.name) + .join(', ') || 'None selected'; + }, [vips, formData.vipIds]); return ( <> @@ -452,11 +445,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction onChange={handleChange} className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary" > - - - - - + {Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => ( + + ))}
@@ -470,10 +463,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraAction onChange={handleChange} className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary" > - - - - + {Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => ( + + ))}
diff --git a/frontend/src/components/VIPForm.tsx b/frontend/src/components/VIPForm.tsx index 4b06569..689ac70 100644 --- a/frontend/src/components/VIPForm.tsx +++ b/frontend/src/components/VIPForm.tsx @@ -1,5 +1,7 @@ import { useState } from 'react'; import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react'; +import { toDatetimeLocal } from '@/lib/utils'; +import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels'; interface VIPFormProps { vip?: VIP | null; @@ -44,18 +46,6 @@ export interface VIPFormData { } export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) { - // Helper to convert ISO datetime to datetime-local format - const toDatetimeLocal = (isoString: string | null) => { - if (!isoString) return ''; - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - const [formData, setFormData] = useState({ name: vip?.name || '', organization: vip?.organization || '', @@ -194,9 +184,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary" style={{ minHeight: '44px' }} > - - - + {Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ( + + ))} @@ -213,8 +205,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary" style={{ minHeight: '44px' }} > - - + {Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => ( + + ))} diff --git a/frontend/src/hooks/useFlights.ts b/frontend/src/hooks/useFlights.ts index ab3c9ee..5e12494 100644 --- a/frontend/src/hooks/useFlights.ts +++ b/frontend/src/hooks/useFlights.ts @@ -2,10 +2,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { Flight, FlightBudget } from '@/types'; import toast from 'react-hot-toast'; +import { queryKeys } from '@/lib/query-keys'; export function useFlights() { return useQuery({ - queryKey: ['flights'], + queryKey: queryKeys.flights.all, queryFn: async () => { const { data } = await api.get('/flights'); return data; @@ -16,7 +17,7 @@ export function useFlights() { export function useFlightBudget() { return useQuery({ - queryKey: ['flights', 'budget'], + queryKey: queryKeys.flights.budget, queryFn: async () => { const { data } = await api.get('/flights/tracking/budget'); return data; @@ -34,8 +35,8 @@ export function useRefreshFlight() { return data; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['flights'] }); - queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.flights.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget }); const status = data.status || 'unknown'; toast.success(`Flight updated: ${data.flightNumber} (${status})`); }, @@ -54,8 +55,8 @@ export function useRefreshActiveFlights() { return data; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['flights'] }); - queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.flights.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget }); toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`); }, onError: (error: any) => { diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index da0f84a..aa3346d 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -11,6 +11,7 @@ import type { DeviceQrInfo, } from '@/types/gps'; import toast from 'react-hot-toast'; +import { queryKeys } from '@/lib/query-keys'; // ============================================ // Admin GPS Hooks @@ -21,7 +22,7 @@ import toast from 'react-hot-toast'; */ export function useGpsStatus() { return useQuery({ - queryKey: ['gps', 'status'], + queryKey: queryKeys.gps.status, queryFn: async () => { const { data } = await api.get('/gps/status'); return data; @@ -35,7 +36,7 @@ export function useGpsStatus() { */ export function useGpsSettings() { return useQuery({ - queryKey: ['gps', 'settings'], + queryKey: queryKeys.gps.settings, queryFn: async () => { const { data } = await api.get('/gps/settings'); return data; @@ -55,8 +56,8 @@ export function useUpdateGpsSettings() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] }); - queryClient.invalidateQueries({ queryKey: ['gps', 'status'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.settings }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.status }); toast.success('GPS settings updated'); }, onError: (error: any) => { @@ -70,7 +71,7 @@ export function useUpdateGpsSettings() { */ export function useGpsDevices() { return useQuery({ - queryKey: ['gps', 'devices'], + queryKey: queryKeys.gps.devices, queryFn: async () => { const { data } = await api.get('/gps/devices'); return data; @@ -84,7 +85,7 @@ export function useGpsDevices() { */ export function useDeviceQr(driverId: string | null) { return useQuery({ - queryKey: ['gps', 'devices', driverId, 'qr'], + queryKey: driverId ? queryKeys.gps.deviceQr(driverId) : ['gps', 'devices', null, 'qr'], queryFn: async () => { const { data } = await api.get(`/gps/devices/${driverId}/qr`); return data; @@ -98,7 +99,7 @@ export function useDeviceQr(driverId: string | null) { */ export function useDriverLocations() { return useQuery({ - queryKey: ['gps', 'locations'], + queryKey: queryKeys.gps.locations.all, queryFn: async () => { const { data } = await api.get('/gps/locations'); return data; @@ -112,7 +113,7 @@ export function useDriverLocations() { */ export function useDriverLocation(driverId: string) { return useQuery({ - queryKey: ['gps', 'locations', driverId], + queryKey: queryKeys.gps.locations.detail(driverId), queryFn: async () => { const { data } = await api.get(`/gps/locations/${driverId}`); return data; @@ -127,7 +128,7 @@ export function useDriverLocation(driverId: string) { */ export function useDriverStats(driverId: string, from?: string, to?: string) { return useQuery({ - queryKey: ['gps', 'stats', driverId, from, to], + queryKey: queryKeys.gps.stats(driverId, from, to), queryFn: async () => { const params = new URLSearchParams(); if (from) params.append('from', from); @@ -151,9 +152,9 @@ export function useEnrollDriver() { return data; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] }); - queryClient.invalidateQueries({ queryKey: ['gps', 'status'] }); - queryClient.invalidateQueries({ queryKey: ['drivers'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all }); if (data.signalMessageSent) { toast.success('Driver enrolled! Setup instructions sent via Signal.'); } else { @@ -178,10 +179,10 @@ export function useUnenrollDriver() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] }); - queryClient.invalidateQueries({ queryKey: ['gps', 'status'] }); - queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] }); - queryClient.invalidateQueries({ queryKey: ['drivers'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.devices }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.locations.all }); + queryClient.invalidateQueries({ queryKey: queryKeys.drivers.all }); toast.success('Driver unenrolled from GPS tracking'); }, onError: (error: any) => { @@ -199,7 +200,7 @@ export function useUnenrollDriver() { */ export function useMyGpsStatus() { return useQuery({ - queryKey: ['gps', 'me'], + queryKey: queryKeys.gps.me.status, queryFn: async () => { const { data } = await api.get('/gps/me'); return data; @@ -212,7 +213,7 @@ export function useMyGpsStatus() { */ export function useMyGpsStats(from?: string, to?: string) { return useQuery({ - queryKey: ['gps', 'me', 'stats', from, to], + queryKey: queryKeys.gps.me.stats(from, to), queryFn: async () => { const params = new URLSearchParams(); if (from) params.append('from', from); @@ -228,7 +229,7 @@ export function useMyGpsStats(from?: string, to?: string) { */ export function useMyLocation() { return useQuery({ - queryKey: ['gps', 'me', 'location'], + queryKey: queryKeys.gps.me.location, queryFn: async () => { const { data } = await api.get('/gps/me/location'); return data; @@ -249,7 +250,7 @@ export function useUpdateGpsConsent() { return data; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['gps', 'me'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.me.status }); toast.success(data.message); }, onError: (error: any) => { @@ -267,7 +268,7 @@ export function useUpdateGpsConsent() { */ export function useTraccarSetupStatus() { return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({ - queryKey: ['gps', 'traccar', 'status'], + queryKey: queryKeys.gps.traccar.status, queryFn: async () => { const { data } = await api.get('/gps/traccar/status'); return data; @@ -287,8 +288,8 @@ export function useTraccarSetup() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] }); - queryClient.invalidateQueries({ queryKey: ['gps', 'status'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.traccar.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.gps.status }); toast.success('Traccar setup complete!'); }, onError: (error: any) => { @@ -320,7 +321,7 @@ export function useSyncAdminsToTraccar() { */ export function useTraccarAdminUrl() { return useQuery<{ url: string; directAccess: boolean }>({ - queryKey: ['gps', 'traccar', 'admin-url'], + queryKey: queryKeys.gps.traccar.adminUrl, queryFn: async () => { const { data } = await api.get('/gps/traccar/admin-url'); return data; diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index e9577ee..c5ef9c9 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -1,13 +1,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../lib/api'; import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings'; +import { queryKeys } from '../lib/query-keys'; /** * Fetch PDF settings */ export function usePdfSettings() { return useQuery({ - queryKey: ['settings', 'pdf'], + queryKey: queryKeys.settings.pdf, queryFn: async () => { const { data } = await api.get('/settings/pdf'); return data; @@ -27,7 +28,7 @@ export function useUpdatePdfSettings() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf }); }, }); } @@ -51,7 +52,7 @@ export function useUploadLogo() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf }); }, }); } @@ -68,7 +69,7 @@ export function useDeleteLogo() { return data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf }); }, }); } diff --git a/frontend/src/hooks/useSignalMessages.ts b/frontend/src/hooks/useSignalMessages.ts index 14a1d60..4ee0886 100644 --- a/frontend/src/hooks/useSignalMessages.ts +++ b/frontend/src/hooks/useSignalMessages.ts @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../lib/api'; +import { queryKeys } from '../lib/query-keys'; export interface SignalMessage { id: string; @@ -19,7 +20,7 @@ export interface UnreadCounts { */ export function useDriverMessages(driverId: string | null, enabled = true) { return useQuery({ - queryKey: ['signal-messages', driverId], + queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null], queryFn: async () => { if (!driverId) return []; const { data } = await api.get(`/signal/messages/driver/${driverId}`); @@ -35,7 +36,7 @@ export function useDriverMessages(driverId: string | null, enabled = true) { */ export function useUnreadCounts() { return useQuery({ - queryKey: ['signal-unread-counts'], + queryKey: queryKeys.signal.unreadCounts, queryFn: async () => { const { data } = await api.get('/signal/messages/unread'); return data; @@ -54,8 +55,10 @@ export function useDriverResponseCheck( // Only include events that have a driver const eventsWithDrivers = events.filter((e) => e.driver?.id); + const eventIds = eventsWithDrivers.map((e) => e.id).join(','); + return useQuery({ - queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')], + queryKey: queryKeys.signal.driverResponses(eventIds), queryFn: async () => { if (eventsWithDrivers.length === 0) { return new Set(); @@ -97,11 +100,11 @@ export function useSendMessage() { onSuccess: (data, variables) => { // Add the new message to the cache immediately queryClient.setQueryData( - ['signal-messages', variables.driverId], + queryKeys.signal.messages(variables.driverId), (old) => [...(old || []), data] ); // Also invalidate to ensure consistency - queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] }); + queryClient.invalidateQueries({ queryKey: queryKeys.signal.messages(variables.driverId) }); }, }); } @@ -120,7 +123,7 @@ export function useMarkMessagesAsRead() { onSuccess: (_, driverId) => { // Update the unread counts cache queryClient.setQueryData( - ['signal-unread-counts'], + queryKeys.signal.unreadCounts, (old) => { if (!old) return {}; const updated = { ...old }; @@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() { ); // Mark messages as read in the messages cache queryClient.setQueryData( - ['signal-messages', driverId], + queryKeys.signal.messages(driverId), (old) => old?.map((msg) => ({ ...msg, isRead: true })) || [] ); }, diff --git a/frontend/src/lib/enum-labels.ts b/frontend/src/lib/enum-labels.ts new file mode 100644 index 0000000..51f0f6c --- /dev/null +++ b/frontend/src/lib/enum-labels.ts @@ -0,0 +1,41 @@ +/** + * Enum Display Labels + * Centralized mapping of enum values to human-readable labels + */ + +export const DEPARTMENT_LABELS: Record = { + OFFICE_OF_DEVELOPMENT: 'Office of Development', + ADMIN: 'Admin', + OTHER: 'Other', +}; + +export const ARRIVAL_MODE_LABELS: Record = { + FLIGHT: 'Flight', + SELF_DRIVING: 'Self Driving', +}; + +export const EVENT_TYPE_LABELS: Record = { + TRANSPORT: 'Transport', + MEETING: 'Meeting', + EVENT: 'Event', + MEAL: 'Meal', + ACCOMMODATION: 'Accommodation', +}; + +export const EVENT_STATUS_LABELS: Record = { + 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 { + return labels[value] || value; +} diff --git a/frontend/src/lib/query-keys.ts b/frontend/src/lib/query-keys.ts new file mode 100644 index 0000000..4acbc51 --- /dev/null +++ b/frontend/src/lib/query-keys.ts @@ -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; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b2dfd05..6d1c6d0 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -35,3 +35,18 @@ export function formatTime(date: string | Date, timeZone?: string): string { ...(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}`; +} diff --git a/frontend/src/pages/DriverList.tsx b/frontend/src/pages/DriverList.tsx index 1a82977..9deb09d 100644 --- a/frontend/src/pages/DriverList.tsx +++ b/frontend/src/pages/DriverList.tsx @@ -8,11 +8,13 @@ import { DriverForm, DriverFormData } from '@/components/DriverForm'; import { TableSkeleton, CardSkeleton } from '@/components/Skeleton'; import { FilterModal } from '@/components/FilterModal'; import { FilterChip } from '@/components/FilterChip'; +import { ConfirmModal } from '@/components/ConfirmModal'; import { useDebounce } from '@/hooks/useDebounce'; import { DriverChatBubble } from '@/components/DriverChatBubble'; import { DriverChatModal } from '@/components/DriverChatModal'; import { DriverScheduleModal } from '@/components/DriverScheduleModal'; import { useUnreadCounts } from '@/hooks/useSignalMessages'; +import { DEPARTMENT_LABELS } from '@/lib/enum-labels'; export function DriverList({ embedded = false }: { embedded?: boolean }) { const queryClient = useQueryClient(); @@ -31,6 +33,9 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { // Schedule modal state const [scheduleDriver, setScheduleDriver] = useState(null); + // Confirm delete modal state + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); + // Sort state const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); @@ -193,12 +198,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { }; const getFilterLabel = (value: string) => { - const labels = { - 'OFFICE_OF_DEVELOPMENT': 'Office of Development', - 'ADMIN': 'Admin', - 'OTHER': 'Other', - }; - return labels[value as keyof typeof labels] || value; + return DEPARTMENT_LABELS[value] || value; }; const handleAdd = () => { @@ -212,8 +212,13 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { }; const handleDelete = (id: string, name: string) => { - if (confirm(`Delete driver "${name}"? This action cannot be undone.`)) { - deleteMutation.mutate(id); + setDeleteConfirm({ id, name }); + }; + + const confirmDelete = () => { + if (deleteConfirm) { + deleteMutation.mutate(deleteConfirm.id); + setDeleteConfirm(null); } }; @@ -539,11 +544,7 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { filterGroups={[ { label: 'Department', - options: [ - { value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' }, - { value: 'ADMIN', label: 'Admin' }, - { value: 'OTHER', label: 'Other' }, - ], + options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })), selectedValues: selectedDepartments, onToggle: handleDepartmentToggle, }, @@ -565,6 +566,17 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) { isOpen={!!scheduleDriver} onClose={() => setScheduleDriver(null)} /> + + {/* Confirm Delete Modal */} + setDeleteConfirm(null)} + title="Delete Driver" + description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`} + confirmLabel="Delete" + variant="destructive" + /> ); } diff --git a/frontend/src/pages/EventList.tsx b/frontend/src/pages/EventList.tsx index 0ca7bf3..00c1a9c 100644 --- a/frontend/src/pages/EventList.tsx +++ b/frontend/src/pages/EventList.tsx @@ -8,7 +8,9 @@ import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'luc import { EventForm, EventFormData } from '@/components/EventForm'; import { Loading } from '@/components/Loading'; import { InlineDriverSelector } from '@/components/InlineDriverSelector'; +import { ConfirmModal } from '@/components/ConfirmModal'; import { useFormattedDate } from '@/hooks/useFormattedDate'; +import { queryKeys } from '@/lib/query-keys'; type ActivityFilter = 'ALL' | EventType; type SortField = 'title' | 'type' | 'startTime' | 'status' | 'vips'; @@ -18,7 +20,7 @@ export function EventList() { const queryClient = useQueryClient(); const location = useLocation(); const navigate = useNavigate(); - const { formatDate, formatDateTime, formatTime } = useFormattedDate(); + const { formatDateTime } = useFormattedDate(); const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -27,8 +29,11 @@ export function EventList() { const [sortField, setSortField] = useState('startTime'); const [sortDirection, setSortDirection] = useState('asc'); + // Confirm delete modal state + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null); + const { data: events, isLoading } = useQuery({ - queryKey: ['events'], + queryKey: queryKeys.events.all, queryFn: async () => { const { data } = await api.get('/events'); return data; @@ -54,7 +59,7 @@ export function EventList() { await api.post('/events', data); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['events'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.events.all }); setShowForm(false); setIsSubmitting(false); toast.success('Event created successfully'); @@ -71,7 +76,7 @@ export function EventList() { await api.patch(`/events/${id}`, data); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['events'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.events.all }); setShowForm(false); setEditingEvent(null); setIsSubmitting(false); @@ -89,7 +94,7 @@ export function EventList() { await api.delete(`/events/${id}`); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['events'] }); + queryClient.invalidateQueries({ queryKey: queryKeys.events.all }); toast.success('Event deleted successfully'); }, onError: (error: any) => { @@ -109,8 +114,13 @@ export function EventList() { }; const handleDelete = (id: string, title: string) => { - if (confirm(`Delete event "${title}"? This action cannot be undone.`)) { - deleteMutation.mutate(id); + setDeleteConfirm({ id, title }); + }; + + const confirmDelete = () => { + if (deleteConfirm) { + deleteMutation.mutate(deleteConfirm.id); + setDeleteConfirm(null); } }; @@ -504,6 +514,17 @@ export function EventList() { isSubmitting={isSubmitting} /> )} + + {/* Confirm Delete Modal */} + setDeleteConfirm(null)} + title="Delete Activity" + description={`Are you sure you want to delete "${deleteConfirm?.title}"? This action cannot be undone.`} + confirmLabel="Delete" + variant="destructive" + /> ); } diff --git a/frontend/src/pages/UserList.tsx b/frontend/src/pages/UserList.tsx index 821dccf..0d7001e 100644 --- a/frontend/src/pages/UserList.tsx +++ b/frontend/src/pages/UserList.tsx @@ -5,6 +5,7 @@ import { useFormattedDate } from '@/hooks/useFormattedDate'; import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react'; import { useState } from 'react'; import { Loading } from '@/components/Loading'; +import { ConfirmModal } from '@/components/ConfirmModal'; interface User { id: string; @@ -19,11 +20,21 @@ interface User { } | null; } +type ConfirmAction = 'approve' | 'delete' | 'changeRole'; + export function UserList() { const queryClient = useQueryClient(); const { formatDate } = useFormattedDate(); const [processingUser, setProcessingUser] = useState(null); + // Confirm modal state + const [confirmState, setConfirmState] = useState<{ + action: ConfirmAction; + userId: string; + userName: string; + newRole?: string; + } | null>(null); + const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: async () => { @@ -93,23 +104,84 @@ export function UserList() { }, }); - const handleRoleChange = (userId: string, newRole: string) => { - if (confirm(`Change user role to ${newRole}?`)) { - changeRoleMutation.mutate({ userId, role: newRole }); - } + const handleRoleChange = (userId: string, userName: string, newRole: string) => { + setConfirmState({ + action: 'changeRole', + userId, + userName, + newRole, + }); }; - const handleApprove = (userId: string) => { - if (confirm('Approve this user?')) { - setProcessingUser(userId); - approveMutation.mutate(userId); - } + const handleApprove = (userId: string, userName: string) => { + setConfirmState({ + action: 'approve', + userId, + userName, + }); }; - const handleDeny = (userId: string) => { - if (confirm('Delete this user? This action cannot be undone.')) { - setProcessingUser(userId); - deleteUserMutation.mutate(userId); + const handleDeny = (userId: string, userName: string) => { + setConfirmState({ + action: 'delete', + userId, + userName, + }); + }; + + const handleConfirm = () => { + if (!confirmState) return; + + const { action, userId, newRole } = confirmState; + + switch (action) { + case 'approve': + setProcessingUser(userId); + approveMutation.mutate(userId); + break; + case 'delete': + setProcessingUser(userId); + deleteUserMutation.mutate(userId); + break; + case 'changeRole': + if (newRole) { + changeRoleMutation.mutate({ userId, role: newRole }); + } + break; + } + + setConfirmState(null); + }; + + const getConfirmModalProps = () => { + if (!confirmState) return null; + + const { action, userName, newRole } = confirmState; + + switch (action) { + case 'approve': + return { + title: 'Approve User', + description: `Are you sure you want to approve ${userName}?`, + confirmLabel: 'Approve', + variant: 'default' as const, + }; + case 'delete': + return { + title: 'Delete User', + description: `Are you sure you want to delete ${userName}? This action cannot be undone.`, + confirmLabel: 'Delete', + variant: 'destructive' as const, + }; + case 'changeRole': + return { + title: 'Change User Role', + description: `Are you sure you want to change ${userName}'s role to ${newRole}?`, + confirmLabel: 'Change Role', + variant: 'warning' as const, + }; + default: + return null; } }; @@ -175,7 +247,7 @@ export function UserList() {
+ + {/* Confirm Modal */} + {confirmState && getConfirmModalProps() && ( + setConfirmState(null)} + {...getConfirmModalProps()!} + /> + )} ); } diff --git a/frontend/src/pages/VipList.tsx b/frontend/src/pages/VipList.tsx index d081782..0cd6af3 100644 --- a/frontend/src/pages/VipList.tsx +++ b/frontend/src/pages/VipList.tsx @@ -9,7 +9,9 @@ import { VIPForm, VIPFormData } from '@/components/VIPForm'; import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton'; import { FilterModal } from '@/components/FilterModal'; import { FilterChip } from '@/components/FilterChip'; +import { ConfirmModal } from '@/components/ConfirmModal'; import { useDebounce } from '@/hooks/useDebounce'; +import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels'; export function VIPList() { const queryClient = useQueryClient(); @@ -31,6 +33,9 @@ export function VIPList() { // Roster-only toggle (hidden by default) const [showRosterOnly, setShowRosterOnly] = useState(false); + // Confirm delete modal state + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); + // Debounce search term for better performance const debouncedSearchTerm = useDebounce(searchTerm, 300); @@ -188,18 +193,8 @@ export function VIPList() { }; const getFilterLabel = (value: string, type: 'department' | 'arrivalMode') => { - const labels = { - department: { - 'OFFICE_OF_DEVELOPMENT': 'Office of Development', - 'ADMIN': 'Admin', - 'OTHER': 'Other', - }, - arrivalMode: { - 'FLIGHT': 'Flight', - 'SELF_DRIVING': 'Self Driving', - }, - }; - return labels[type][value as keyof typeof labels[typeof type]] || value; + const labels = type === 'department' ? DEPARTMENT_LABELS : ARRIVAL_MODE_LABELS; + return labels[value] || value; }; const handleAdd = () => { @@ -213,8 +208,13 @@ export function VIPList() { }; const handleDelete = (id: string, name: string) => { - if (confirm(`Delete VIP "${name}"? This action cannot be undone.`)) { - deleteMutation.mutate(id); + setDeleteConfirm({ id, name }); + }; + + const confirmDelete = () => { + if (deleteConfirm) { + deleteMutation.mutate(deleteConfirm.id); + setDeleteConfirm(null); } }; @@ -290,7 +290,7 @@ export function VIPList() { {/* Filter Button */}