refactor: code efficiency improvements (Issues #9-13, #15, #17-20)
Backend: - Extract shared hard-delete authorization utility (#9) - Extract Prisma include constants per entity (#11) - Fix N+1 query pattern in events findAll (#12) - Extract shared date utility functions (#13) - Move vehicle utilization filtering to DB query (#15) - Add ParseBooleanPipe for query params - Add CurrentDriver decorator + ResolveDriverInterceptor (#20) Frontend: - Extract shared form utilities (toDatetimeLocal) and enum labels (#17) - Replace browser confirm() with styled ConfirmModal (#18) - Add centralized query-keys.ts constants (#19) - Clean up unused imports, add useMemo where needed (#19) - Standardize filter button styling across list pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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';
|
||||
@@ -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<ToolResult> {
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<string, any>();
|
||||
|
||||
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<string, any>): Promise<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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);
|
||||
|
||||
13
backend/src/drivers/decorators/current-driver.decorator.ts
Normal file
13
backend/src/drivers/decorators/current-driver.decorator.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Parameter decorator that extracts the current driver from the request.
|
||||
* Should be used in conjunction with @UseInterceptors(ResolveDriverInterceptor)
|
||||
* to ensure the driver is pre-resolved and attached to the request.
|
||||
*/
|
||||
export const CurrentDriver = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.driver;
|
||||
},
|
||||
);
|
||||
1
backend/src/drivers/decorators/index.ts
Normal file
1
backend/src/drivers/decorators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './current-driver.decorator';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DriversService } from './drivers.service';
|
||||
@@ -16,8 +17,12 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { CurrentDriver } from './decorators';
|
||||
import { ResolveDriverInterceptor } from './interceptors';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CreateDriverDto, UpdateDriverDto } from './dto';
|
||||
import { toDateString } from '../common/utils/date.utils';
|
||||
import { ParseBooleanPipe } from '../common/pipes';
|
||||
|
||||
@Controller('drivers')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@@ -41,11 +46,8 @@ export class DriversController {
|
||||
|
||||
@Get('me')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async getMyDriverProfile(@CurrentUser() user: any) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
@UseInterceptors(ResolveDriverInterceptor)
|
||||
getMyDriverProfile(@CurrentDriver() driver: any) {
|
||||
return driver;
|
||||
}
|
||||
|
||||
@@ -55,22 +57,19 @@ export class DriversController {
|
||||
*/
|
||||
@Get('me/schedule/ics')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
@UseInterceptors(ResolveDriverInterceptor)
|
||||
async getMyScheduleICS(
|
||||
@CurrentUser() user: any,
|
||||
@CurrentDriver() driver: any,
|
||||
@Query('date') dateStr?: string,
|
||||
@Query('fullSchedule') fullScheduleStr?: string,
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = dateStr ? new Date(dateStr) : new Date();
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
const fullSchedule = fullScheduleStr !== 'false';
|
||||
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
|
||||
const filename = fullSchedule
|
||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
|
||||
: `schedule-${date.toISOString().split('T')[0]}.ics`;
|
||||
? `full-schedule-${toDateString(new Date())}.ics`
|
||||
: `schedule-${toDateString(date)}.ics`;
|
||||
return { ics: icsContent, filename };
|
||||
}
|
||||
|
||||
@@ -80,22 +79,19 @@ export class DriversController {
|
||||
*/
|
||||
@Get('me/schedule/pdf')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
@UseInterceptors(ResolveDriverInterceptor)
|
||||
async getMySchedulePDF(
|
||||
@CurrentUser() user: any,
|
||||
@CurrentDriver() driver: any,
|
||||
@Query('date') dateStr?: string,
|
||||
@Query('fullSchedule') fullScheduleStr?: string,
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = dateStr ? new Date(dateStr) : new Date();
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
const fullSchedule = fullScheduleStr !== 'false';
|
||||
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
|
||||
const filename = fullSchedule
|
||||
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
|
||||
? `full-schedule-${toDateString(new Date())}.pdf`
|
||||
: `schedule-${toDateString(date)}.pdf`;
|
||||
return { pdf: pdfBuffer.toString('base64'), filename };
|
||||
}
|
||||
|
||||
@@ -105,14 +101,11 @@ export class DriversController {
|
||||
*/
|
||||
@Post('me/send-schedule')
|
||||
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
@UseInterceptors(ResolveDriverInterceptor)
|
||||
async sendMySchedule(
|
||||
@CurrentUser() user: any,
|
||||
@CurrentDriver() driver: any,
|
||||
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
|
||||
) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
const date = body.date ? new Date(body.date) : new Date();
|
||||
const format = body.format || 'both';
|
||||
// Default to full schedule (true) unless explicitly set to false
|
||||
@@ -122,11 +115,8 @@ export class DriversController {
|
||||
|
||||
@Patch('me')
|
||||
@Roles(Role.DRIVER)
|
||||
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
@UseInterceptors(ResolveDriverInterceptor)
|
||||
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
|
||||
return this.driversService.update(driver.id, updateDriverDto);
|
||||
}
|
||||
|
||||
@@ -219,10 +209,9 @@ export class DriversController {
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Query('hard') hard?: string,
|
||||
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
@CurrentUser() user?: any,
|
||||
) {
|
||||
const isHardDelete = hard === 'true';
|
||||
return this.driversService.remove(id, isHardDelete, user?.role);
|
||||
return this.driversService.remove(id, hard, user?.role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,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 },
|
||||
return executeHardDelete({
|
||||
id,
|
||||
hardDelete,
|
||||
userRole,
|
||||
findOne: (id) => this.findOne(id),
|
||||
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
|
||||
performSoftDelete: (id) =>
|
||||
this.prisma.driver.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
}),
|
||||
entityName: 'Driver',
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
1
backend/src/drivers/interceptors/index.ts
Normal file
1
backend/src/drivers/interceptors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './resolve-driver.interceptor';
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DriversService } from '../drivers.service';
|
||||
|
||||
/**
|
||||
* Interceptor that resolves the current driver from the authenticated user
|
||||
* and attaches it to the request object for /me routes.
|
||||
* This prevents multiple calls to findByUserId() in each route handler.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResolveDriverInterceptor implements NestInterceptor {
|
||||
constructor(private readonly driversService: DriversService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not authenticated');
|
||||
}
|
||||
|
||||
// Resolve driver from user ID and attach to request
|
||||
const driver = await this.driversService.findByUserId(user.id);
|
||||
|
||||
if (!driver) {
|
||||
throw new NotFoundException('Driver profile not found for current user');
|
||||
}
|
||||
|
||||
// Attach driver to request for use in route handlers
|
||||
request.driver = driver;
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
import * as ics from 'ics';
|
||||
import * as PDFDocument from 'pdfkit';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ScheduleEventWithDetails {
|
||||
id: string;
|
||||
@@ -36,8 +37,7 @@ export class ScheduleExportService {
|
||||
driverId: string,
|
||||
date: Date,
|
||||
): Promise<ScheduleEventWithDetails[]> {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const dayStart = startOfDay(date);
|
||||
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
@@ -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<ScheduleEventWithDetails[]> {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0); // Start of today
|
||||
const now = startOfDay(new Date()); // Start of today
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
|
||||
import { ParseBooleanPipe } from '../common/pipes';
|
||||
|
||||
@Controller('events')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@@ -59,10 +60,9 @@ export class EventsController {
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Query('hard') hard?: string,
|
||||
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
@CurrentUser() user?: any,
|
||||
) {
|
||||
const isHardDelete = hard === 'true';
|
||||
return this.eventsService.remove(id, isHardDelete, user?.role);
|
||||
return this.eventsService.remove(id, hard, user?.role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,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<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) },
|
||||
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 },
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Flight } from '@prisma/client';
|
||||
import { toDateString } from '../common/utils/date.utils';
|
||||
|
||||
// Tracking phases - determines polling priority
|
||||
const PHASE = {
|
||||
@@ -319,7 +320,7 @@ export class FlightTrackingService {
|
||||
|
||||
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
|
||||
const flightDate = flight.flightDate
|
||||
? new Date(flight.flightDate).toISOString().split('T')[0]
|
||||
? toDateString(new Date(flight.flightDate))
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||
import { ParseBooleanPipe } from '../common/pipes';
|
||||
|
||||
@Controller('flights')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@@ -88,9 +89,8 @@ export class FlightsController {
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Query('hard') hard?: string,
|
||||
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
) {
|
||||
const isHardDelete = hard === 'true';
|
||||
return this.flightsService.remove(id, isHardDelete);
|
||||
return this.flightsService.remove(id, hard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateFlightDto, UpdateFlightDto } from './dto';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { convertOptionalDates } from '../common/utils/date.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FlightsService {
|
||||
@@ -24,17 +25,16 @@ export class FlightsService {
|
||||
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
|
||||
);
|
||||
|
||||
return this.prisma.flight.create({
|
||||
data: {
|
||||
const data = convertOptionalDates(
|
||||
{
|
||||
...createFlightDto,
|
||||
flightDate: new Date(createFlightDto.flightDate),
|
||||
scheduledDeparture: createFlightDto.scheduledDeparture
|
||||
? new Date(createFlightDto.scheduledDeparture)
|
||||
: undefined,
|
||||
scheduledArrival: createFlightDto.scheduledArrival
|
||||
? new Date(createFlightDto.scheduledArrival)
|
||||
: undefined,
|
||||
},
|
||||
['scheduledDeparture', 'scheduledArrival'],
|
||||
);
|
||||
|
||||
return this.prisma.flight.create({
|
||||
data,
|
||||
include: { vip: true },
|
||||
});
|
||||
}
|
||||
@@ -71,24 +71,13 @@ export class FlightsService {
|
||||
|
||||
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
|
||||
|
||||
const updateData: any = { ...updateFlightDto };
|
||||
const dto = updateFlightDto as any; // Type assertion to work around PartialType
|
||||
|
||||
if (dto.flightDate) {
|
||||
updateData.flightDate = new Date(dto.flightDate);
|
||||
}
|
||||
if (dto.scheduledDeparture) {
|
||||
updateData.scheduledDeparture = new Date(dto.scheduledDeparture);
|
||||
}
|
||||
if (dto.scheduledArrival) {
|
||||
updateData.scheduledArrival = new Date(dto.scheduledArrival);
|
||||
}
|
||||
if (dto.actualDeparture) {
|
||||
updateData.actualDeparture = new Date(dto.actualDeparture);
|
||||
}
|
||||
if (dto.actualArrival) {
|
||||
updateData.actualArrival = new Date(dto.actualArrival);
|
||||
}
|
||||
const updateData = convertOptionalDates(updateFlightDto, [
|
||||
'flightDate',
|
||||
'scheduledDeparture',
|
||||
'scheduledArrival',
|
||||
'actualDeparture',
|
||||
'actualArrival',
|
||||
]);
|
||||
|
||||
return this.prisma.flight.update({
|
||||
where: { id: flight.id },
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
|
||||
import { ParseBooleanPipe } from '../common/pipes';
|
||||
|
||||
@Controller('vehicles')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@@ -59,10 +60,9 @@ export class VehiclesController {
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Query('hard') hard?: string,
|
||||
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
@CurrentUser() user?: any,
|
||||
) {
|
||||
const isHardDelete = hard === 'true';
|
||||
return this.vehiclesService.remove(id, isHardDelete, user?.role);
|
||||
return this.vehiclesService.remove(id, hard, user?.role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,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 },
|
||||
return executeHardDelete({
|
||||
id,
|
||||
hardDelete,
|
||||
userRole,
|
||||
findOne: (id) => this.findOne(id),
|
||||
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
|
||||
performSoftDelete: (id) =>
|
||||
this.prisma.vehicle.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
}),
|
||||
entityName: 'Vehicle',
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,23 +94,34 @@ export class VehiclesService {
|
||||
* Get vehicle utilization statistics
|
||||
*/
|
||||
async getUtilization() {
|
||||
const vehicles = await this.findAll();
|
||||
const now = new Date();
|
||||
|
||||
const stats = vehicles.map((vehicle) => {
|
||||
const upcomingEvents = vehicle.events.filter(
|
||||
(event) => new Date(event.startTime) > new Date(),
|
||||
);
|
||||
// Fetch vehicles with only upcoming events (filtered at database level)
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
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' },
|
||||
});
|
||||
|
||||
return {
|
||||
const stats = vehicles.map((vehicle) => ({
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
upcomingTrips: upcomingEvents.length,
|
||||
upcomingTrips: vehicle.events.length, // Already filtered at DB level
|
||||
currentDriver: vehicle.currentDriver?.name,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
totalVehicles: vehicles.length,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AbilitiesGuard } from '../auth/guards/abilities.guard';
|
||||
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||
import { ParseBooleanPipe } from '../common/pipes';
|
||||
|
||||
@Controller('vips')
|
||||
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||
@@ -49,11 +50,9 @@ export class VipsController {
|
||||
@CanDelete('VIP')
|
||||
remove(
|
||||
@Param('id') id: string,
|
||||
@Query('hard') hard?: string,
|
||||
@Query('hard', ParseBooleanPipe) hard: boolean,
|
||||
@CurrentUser() user?: any,
|
||||
) {
|
||||
// Only administrators can hard delete
|
||||
const isHardDelete = hard === 'true';
|
||||
return this.vipsService.remove(id, isHardDelete, user?.role);
|
||||
return this.vipsService.remove(id, hard, user?.role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateVipDto, UpdateVipDto } from './dto';
|
||||
import { executeHardDelete } from '../common/utils';
|
||||
|
||||
@Injectable()
|
||||
export class VipsService {
|
||||
@@ -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 },
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { DEPARTMENT_LABELS } from '@/lib/enum-labels';
|
||||
|
||||
interface DriverFormProps {
|
||||
driver?: Driver | null;
|
||||
@@ -112,9 +113,11 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="OTHER">Other</option>
|
||||
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<EventFormData>({
|
||||
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<VIP[]>({
|
||||
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<Driver[]>({
|
||||
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<Vehicle[]>({
|
||||
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<ScheduleEvent[]>({
|
||||
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
|
||||
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"
|
||||
>
|
||||
<option value="TRANSPORT">Transport</option>
|
||||
<option value="MEETING">Meeting</option>
|
||||
<option value="EVENT">Event</option>
|
||||
<option value="MEAL">Meal</option>
|
||||
<option value="ACCOMMODATION">Accommodation</option>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -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"
|
||||
>
|
||||
<option value="SCHEDULED">Scheduled</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
{Object.entries(EVENT_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react';
|
||||
import { toDatetimeLocal } from '@/lib/utils';
|
||||
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
|
||||
|
||||
interface VIPFormProps {
|
||||
vip?: VIP | null;
|
||||
@@ -44,18 +46,6 @@ export interface VIPFormData {
|
||||
}
|
||||
|
||||
export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) {
|
||||
// Helper to convert ISO datetime to datetime-local format
|
||||
const toDatetimeLocal = (isoString: string | null) => {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<VIPFormData>({
|
||||
name: vip?.name || '',
|
||||
organization: vip?.organization || '',
|
||||
@@ -194,9 +184,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="OTHER">Other</option>
|
||||
{Object.entries(DEPARTMENT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -213,8 +205,11 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
||||
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<option value="FLIGHT">Flight</option>
|
||||
<option value="SELF_DRIVING">Self Driving</option>
|
||||
{Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Flight, FlightBudget } from '@/types';
|
||||
import toast from 'react-hot-toast';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
export function useFlights() {
|
||||
return useQuery<Flight[]>({
|
||||
queryKey: ['flights'],
|
||||
queryKey: queryKeys.flights.all,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/flights');
|
||||
return data;
|
||||
@@ -16,7 +17,7 @@ export function useFlights() {
|
||||
|
||||
export function useFlightBudget() {
|
||||
return useQuery<FlightBudget>({
|
||||
queryKey: ['flights', 'budget'],
|
||||
queryKey: queryKeys.flights.budget,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/flights/tracking/budget');
|
||||
return data;
|
||||
@@ -34,8 +35,8 @@ export function useRefreshFlight() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
|
||||
const status = data.status || 'unknown';
|
||||
toast.success(`Flight updated: ${data.flightNumber} (${status})`);
|
||||
},
|
||||
@@ -54,8 +55,8 @@ export function useRefreshActiveFlights() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['flights'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['flights', 'budget'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.flights.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.flights.budget });
|
||||
toast.success(`Refreshed ${data.refreshed} flights (${data.budgetRemaining} API calls remaining)`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
|
||||
@@ -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<GpsStatus>({
|
||||
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<GpsSettings>({
|
||||
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<GpsDevice[]>({
|
||||
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<DeviceQrInfo>({
|
||||
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<DriverLocation[]>({
|
||||
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<DriverLocation>({
|
||||
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<DriverStats>({
|
||||
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<MyGpsStatus>({
|
||||
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<DriverStats>({
|
||||
queryKey: ['gps', 'me', 'stats', from, to],
|
||||
queryKey: queryKeys.gps.me.stats(from, to),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.append('from', from);
|
||||
@@ -228,7 +229,7 @@ export function useMyGpsStats(from?: string, to?: string) {
|
||||
*/
|
||||
export function useMyLocation() {
|
||||
return useQuery<DriverLocation>({
|
||||
queryKey: ['gps', 'me', 'location'],
|
||||
queryKey: queryKeys.gps.me.location,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/gps/me/location');
|
||||
return data;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
||||
import { queryKeys } from '../lib/query-keys';
|
||||
|
||||
/**
|
||||
* Fetch PDF settings
|
||||
*/
|
||||
export function usePdfSettings() {
|
||||
return useQuery<PdfSettings>({
|
||||
queryKey: ['settings', 'pdf'],
|
||||
queryKey: queryKeys.settings.pdf,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/settings/pdf');
|
||||
return data;
|
||||
@@ -27,7 +28,7 @@ export function useUpdatePdfSettings() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,7 +52,7 @@ export function useUploadLogo() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -68,7 +69,7 @@ export function useDeleteLogo() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settings.pdf });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { queryKeys } from '../lib/query-keys';
|
||||
|
||||
export interface SignalMessage {
|
||||
id: string;
|
||||
@@ -19,7 +20,7 @@ export interface UnreadCounts {
|
||||
*/
|
||||
export function useDriverMessages(driverId: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['signal-messages', driverId],
|
||||
queryKey: driverId ? queryKeys.signal.messages(driverId) : ['signal-messages', null],
|
||||
queryFn: async () => {
|
||||
if (!driverId) return [];
|
||||
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
||||
@@ -35,7 +36,7 @@ export function useDriverMessages(driverId: string | null, enabled = true) {
|
||||
*/
|
||||
export function useUnreadCounts() {
|
||||
return useQuery({
|
||||
queryKey: ['signal-unread-counts'],
|
||||
queryKey: queryKeys.signal.unreadCounts,
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
||||
return data;
|
||||
@@ -54,8 +55,10 @@ export function useDriverResponseCheck(
|
||||
// Only include events that have a driver
|
||||
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
||||
|
||||
const eventIds = eventsWithDrivers.map((e) => e.id).join(',');
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
|
||||
queryKey: queryKeys.signal.driverResponses(eventIds),
|
||||
queryFn: async () => {
|
||||
if (eventsWithDrivers.length === 0) {
|
||||
return new Set<string>();
|
||||
@@ -97,11 +100,11 @@ export function useSendMessage() {
|
||||
onSuccess: (data, variables) => {
|
||||
// Add the new message to the cache immediately
|
||||
queryClient.setQueryData<SignalMessage[]>(
|
||||
['signal-messages', variables.driverId],
|
||||
queryKeys.signal.messages(variables.driverId),
|
||||
(old) => [...(old || []), data]
|
||||
);
|
||||
// Also invalidate to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.signal.messages(variables.driverId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,7 +123,7 @@ export function useMarkMessagesAsRead() {
|
||||
onSuccess: (_, driverId) => {
|
||||
// Update the unread counts cache
|
||||
queryClient.setQueryData<UnreadCounts>(
|
||||
['signal-unread-counts'],
|
||||
queryKeys.signal.unreadCounts,
|
||||
(old) => {
|
||||
if (!old) return {};
|
||||
const updated = { ...old };
|
||||
@@ -130,7 +133,7 @@ export function useMarkMessagesAsRead() {
|
||||
);
|
||||
// Mark messages as read in the messages cache
|
||||
queryClient.setQueryData<SignalMessage[]>(
|
||||
['signal-messages', driverId],
|
||||
queryKeys.signal.messages(driverId),
|
||||
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
||||
);
|
||||
},
|
||||
|
||||
41
frontend/src/lib/enum-labels.ts
Normal file
41
frontend/src/lib/enum-labels.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Enum Display Labels
|
||||
* Centralized mapping of enum values to human-readable labels
|
||||
*/
|
||||
|
||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
OFFICE_OF_DEVELOPMENT: 'Office of Development',
|
||||
ADMIN: 'Admin',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
|
||||
export const ARRIVAL_MODE_LABELS: Record<string, string> = {
|
||||
FLIGHT: 'Flight',
|
||||
SELF_DRIVING: 'Self Driving',
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
TRANSPORT: 'Transport',
|
||||
MEETING: 'Meeting',
|
||||
EVENT: 'Event',
|
||||
MEAL: 'Meal',
|
||||
ACCOMMODATION: 'Accommodation',
|
||||
};
|
||||
|
||||
export const EVENT_STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Scheduled',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get a label for any enum value
|
||||
* Falls back to the value itself if no mapping is found
|
||||
*/
|
||||
export function getEnumLabel(
|
||||
value: string,
|
||||
labels: Record<string, string>
|
||||
): string {
|
||||
return labels[value] || value;
|
||||
}
|
||||
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;
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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<Driver | null>(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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ScheduleEvent | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -27,8 +29,11 @@ export function EventList() {
|
||||
const [sortField, setSortField] = useState<SortField>('startTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
// Confirm delete modal state
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
||||
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 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
// Confirm modal state
|
||||
const [confirmState, setConfirmState] = useState<{
|
||||
action: ConfirmAction;
|
||||
userId: string;
|
||||
userName: string;
|
||||
newRole?: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: users, isLoading } = useQuery<User[]>({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => {
|
||||
@@ -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?')) {
|
||||
const handleApprove = (userId: string, userName: string) => {
|
||||
setConfirmState({
|
||||
action: 'approve',
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeny = (userId: string, userName: string) => {
|
||||
setConfirmState({
|
||||
action: 'delete',
|
||||
userId,
|
||||
userName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!confirmState) return;
|
||||
|
||||
const { action, userId, newRole } = confirmState;
|
||||
|
||||
switch (action) {
|
||||
case 'approve':
|
||||
setProcessingUser(userId);
|
||||
approveMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = (userId: string) => {
|
||||
if (confirm('Delete this user? This action cannot be undone.')) {
|
||||
break;
|
||||
case 'delete':
|
||||
setProcessingUser(userId);
|
||||
deleteUserMutation.mutate(userId);
|
||||
break;
|
||||
case 'changeRole':
|
||||
if (newRole) {
|
||||
changeRoleMutation.mutate({ userId, role: newRole });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setConfirmState(null);
|
||||
};
|
||||
|
||||
const getConfirmModalProps = () => {
|
||||
if (!confirmState) return null;
|
||||
|
||||
const { action, userName, newRole } = confirmState;
|
||||
|
||||
switch (action) {
|
||||
case 'approve':
|
||||
return {
|
||||
title: 'Approve User',
|
||||
description: `Are you sure you want to approve ${userName}?`,
|
||||
confirmLabel: 'Approve',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
case 'delete':
|
||||
return {
|
||||
title: 'Delete User',
|
||||
description: `Are you sure you want to delete ${userName}? This action cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
variant: 'destructive' as const,
|
||||
};
|
||||
case 'changeRole':
|
||||
return {
|
||||
title: 'Change User Role',
|
||||
description: `Are you sure you want to change ${userName}'s role to ${newRole}?`,
|
||||
confirmLabel: 'Change Role',
|
||||
variant: 'warning' as const,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,7 +247,7 @@ export function UserList() {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleApprove(user.id)}
|
||||
onClick={() => handleApprove(user.id, user.name || user.email)}
|
||||
disabled={processingUser === user.id}
|
||||
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
@@ -183,7 +255,7 @@ export function UserList() {
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(user.id)}
|
||||
onClick={() => handleDeny(user.id, user.name || user.email)}
|
||||
disabled={processingUser === user.id}
|
||||
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
@@ -286,7 +358,7 @@ export function UserList() {
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
onChange={(e) => handleRoleChange(user.id, user.name || user.email, e.target.value)}
|
||||
className="text-sm border border-input bg-background rounded px-2 py-1 focus:ring-primary focus:border-primary transition-colors"
|
||||
>
|
||||
<option value="DRIVER">Driver</option>
|
||||
@@ -294,7 +366,7 @@ export function UserList() {
|
||||
<option value="ADMINISTRATOR">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDeny(user.id)}
|
||||
onClick={() => handleDeny(user.id, user.name || user.email)}
|
||||
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 dark:hover:bg-red-950/20 rounded transition-colors"
|
||||
title="Delete user"
|
||||
>
|
||||
@@ -309,6 +381,16 @@ export function UserList() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
{confirmState && getConfirmModalProps() && (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setConfirmState(null)}
|
||||
{...getConfirmModalProps()!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<button
|
||||
onClick={() => setFilterModalOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-background hover:bg-accent font-medium transition-colors"
|
||||
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent hover:text-accent-foreground font-medium transition-colors"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
@@ -544,20 +544,13 @@ export function VIPList() {
|
||||
filterGroups={[
|
||||
{
|
||||
label: 'Department',
|
||||
options: [
|
||||
{ value: 'OFFICE_OF_DEVELOPMENT', label: 'Office of Development' },
|
||||
{ value: 'ADMIN', label: 'Admin' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
],
|
||||
options: Object.entries(DEPARTMENT_LABELS).map(([value, label]) => ({ value, label })),
|
||||
selectedValues: selectedDepartments,
|
||||
onToggle: handleDepartmentToggle,
|
||||
},
|
||||
{
|
||||
label: 'Arrival Mode',
|
||||
options: [
|
||||
{ value: 'FLIGHT', label: 'Flight' },
|
||||
{ value: 'SELF_DRIVING', label: 'Self Driving' },
|
||||
],
|
||||
options: Object.entries(ARRIVAL_MODE_LABELS).map(([value, label]) => ({ value, label })),
|
||||
selectedValues: selectedArrivalModes,
|
||||
onToggle: handleArrivalModeToggle,
|
||||
},
|
||||
@@ -565,6 +558,17 @@ export function VIPList() {
|
||||
onClear={handleClearFilters}
|
||||
onApply={() => {}}
|
||||
/>
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteConfirm}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
title="Delete VIP"
|
||||
description={`Are you sure you want to delete "${deleteConfirm?.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user