Backend: - Add Prisma soft-delete middleware for automatic deletedAt filtering (#10) - Split 2758-line copilot.service.ts into focused sub-services (#14): - copilot-schedule.service.ts (schedule/event tools) - copilot-reports.service.ts (reporting/analytics tools) - copilot-fleet.service.ts (vehicle/driver tools) - copilot-vip.service.ts (VIP management tools) - copilot.service.ts now thin orchestrator - Remove manual deletedAt: null from 50+ queries Frontend: - Create SortableHeader component for reusable table sorting (#16) - Create useListPage hook for shared search/filter/sort state (#16) - Update VipList, DriverList, EventList to use shared infrastructure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,9 +18,9 @@ export class AuthService {
|
||||
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
|
||||
const picture = payload[`${namespace}/picture`] || payload.picture;
|
||||
|
||||
// Check if user exists (exclude soft-deleted users)
|
||||
// Check if user exists (soft-deleted users automatically excluded by middleware)
|
||||
let user = await this.prisma.user.findFirst({
|
||||
where: { auth0Id, deletedAt: null },
|
||||
where: { auth0Id },
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export class AuthService {
|
||||
// where two simultaneous registrations both become admin
|
||||
user = await this.prisma.$transaction(async (tx) => {
|
||||
const approvedUserCount = await tx.user.count({
|
||||
where: { isApproved: true, deletedAt: null },
|
||||
where: { isApproved: true },
|
||||
});
|
||||
const isFirstUser = approvedUserCount === 0;
|
||||
|
||||
@@ -63,11 +63,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile (excludes soft-deleted users)
|
||||
* Get current user profile (soft-deleted users automatically excluded by middleware)
|
||||
*/
|
||||
async getCurrentUser(auth0Id: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: { auth0Id, deletedAt: null },
|
||||
where: { auth0Id },
|
||||
include: { driver: true },
|
||||
});
|
||||
}
|
||||
|
||||
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
462
backend/src/copilot/copilot-fleet.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotFleetService {
|
||||
private readonly logger = new Logger(CopilotFleetService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAvailableVehicles(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null, status: 'AVAILABLE' };
|
||||
|
||||
if (filters.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
|
||||
if (filters.minSeats) {
|
||||
where.seatCapacity = { gte: filters.minSeats };
|
||||
}
|
||||
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: vehicles,
|
||||
message: `Found ${vehicles.length} available vehicle(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async assignVehicleToEvent(eventId: string, vehicleId: string): Promise<ToolResult> {
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// If vehicleId is null, we're unassigning
|
||||
if (vehicleId === null || vehicleId === 'null') {
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId: null },
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle unassigned from event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify vehicle exists
|
||||
const vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: `Vehicle with ID ${vehicleId} not found.` };
|
||||
}
|
||||
|
||||
const updatedEvent = await this.prisma.scheduleEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { vehicleId },
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedEvent,
|
||||
message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`,
|
||||
};
|
||||
}
|
||||
|
||||
async suggestVehicleForEvent(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { eventId } = input;
|
||||
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id: eventId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return { success: false, error: `Event with ID ${eventId} not found.` };
|
||||
}
|
||||
|
||||
// Fetch VIP info to determine party size
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds } },
|
||||
select: { id: true, name: true, partySize: true },
|
||||
});
|
||||
|
||||
// Determine required capacity based on total party size
|
||||
const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
|
||||
|
||||
// Find vehicles not in use during this event time
|
||||
const busyVehicleIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
id: { not: eventId },
|
||||
status: { not: 'CANCELLED' },
|
||||
vehicleId: { not: null },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: event.startTime },
|
||||
endTime: { gt: event.startTime },
|
||||
},
|
||||
{
|
||||
startTime: { lt: event.endTime },
|
||||
endTime: { gte: event.endTime },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { vehicleId: true },
|
||||
});
|
||||
|
||||
const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null);
|
||||
|
||||
// Find available vehicles with sufficient capacity
|
||||
const suitableVehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'AVAILABLE',
|
||||
seatCapacity: { gte: requiredSeats },
|
||||
id: { notIn: busyIds },
|
||||
},
|
||||
orderBy: [
|
||||
{ seatCapacity: 'asc' }, // Prefer smallest suitable vehicle
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
eventId,
|
||||
eventTitle: event.title,
|
||||
vipNames: vips.map((v) => v.name),
|
||||
requiredSeats,
|
||||
suggestions: suitableVehicles.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
seatCapacity: v.seatCapacity,
|
||||
})),
|
||||
},
|
||||
message:
|
||||
suitableVehicles.length > 0
|
||||
? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).`
|
||||
: `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getVehicleSchedule(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vehicleName, vehicleId, startDate, endDate } = input;
|
||||
|
||||
let vehicle;
|
||||
|
||||
if (vehicleId) {
|
||||
vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
});
|
||||
} else if (vehicleName) {
|
||||
const vehicles = await this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
name: { contains: vehicleName, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
|
||||
if (vehicles.length === 0) {
|
||||
return { success: false, error: `No vehicle found matching "${vehicleName}".` };
|
||||
}
|
||||
|
||||
if (vehicles.length > 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`,
|
||||
};
|
||||
}
|
||||
|
||||
vehicle = vehicles[0];
|
||||
} else {
|
||||
return { success: false, error: 'Either vehicleName or vehicleId is required.' };
|
||||
}
|
||||
|
||||
if (!vehicle) {
|
||||
return { success: false, error: 'Vehicle not found.' };
|
||||
}
|
||||
|
||||
const dateStart = startOfDay(new Date(startDate));
|
||||
const dateEnd = new Date(endDate);
|
||||
dateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vehicleId: vehicle.id,
|
||||
startTime: { gte: dateStart, lte: dateEnd },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const totalHours =
|
||||
events.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
vehicle: {
|
||||
id: vehicle.id,
|
||||
name: vehicle.name,
|
||||
type: vehicle.type,
|
||||
seatCapacity: vehicle.seatCapacity,
|
||||
status: vehicle.status,
|
||||
},
|
||||
dateRange: {
|
||||
start: toDateString(dateStart),
|
||||
end: toDateString(dateEnd),
|
||||
},
|
||||
eventCount: events.length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
events: events.map((e) => ({
|
||||
eventId: e.id,
|
||||
title: e.title,
|
||||
type: e.type,
|
||||
startTime: e.startTime,
|
||||
endTime: e.endTime,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
driverName: e.driver?.name || null,
|
||||
pickupLocation: e.pickupLocation,
|
||||
dropoffLocation: e.dropoffLocation,
|
||||
location: e.location,
|
||||
})),
|
||||
},
|
||||
message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`,
|
||||
};
|
||||
}
|
||||
|
||||
async searchDrivers(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (filters.name) {
|
||||
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
if (filters.department) {
|
||||
where.department = filters.department;
|
||||
}
|
||||
|
||||
if (filters.availableOnly) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) matching the criteria.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getDriverSchedule(
|
||||
driverId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
): Promise<ToolResult> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const where: any = {
|
||||
deletedAt: null,
|
||||
driverId,
|
||||
status: { not: 'CANCELLED' },
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
where.startTime = { gte: new Date(startDate) };
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
where.endTime = { lte: new Date(endDate) };
|
||||
}
|
||||
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where,
|
||||
include: {
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
const eventsWithVipNames = events.map((event) => ({
|
||||
...event,
|
||||
vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
driver,
|
||||
events: eventsWithVipNames,
|
||||
eventCount: events.length,
|
||||
},
|
||||
message: `Driver ${driver.name} has ${events.length} scheduled event(s).`,
|
||||
};
|
||||
}
|
||||
|
||||
async listAllDrivers(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { includeUnavailable = true } = input;
|
||||
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (!includeUnavailable) {
|
||||
where.isAvailable = true;
|
||||
}
|
||||
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
department: true,
|
||||
isAvailable: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: drivers,
|
||||
message: `Found ${drivers.length} driver(s) in the system.`,
|
||||
};
|
||||
}
|
||||
|
||||
async findAvailableDriversForTimerange(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { startTime, endTime, preferredDepartment } = input;
|
||||
|
||||
// Get all drivers
|
||||
const where: any = { deletedAt: null, isAvailable: true };
|
||||
|
||||
if (preferredDepartment) {
|
||||
where.department = preferredDepartment;
|
||||
}
|
||||
|
||||
const allDrivers = await this.prisma.driver.findMany({
|
||||
where,
|
||||
});
|
||||
|
||||
// Find drivers with conflicting events
|
||||
const busyDriverIds = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
driverId: { not: null },
|
||||
status: { not: 'CANCELLED' },
|
||||
OR: [
|
||||
{
|
||||
startTime: { lte: new Date(startTime) },
|
||||
endTime: { gt: new Date(startTime) },
|
||||
},
|
||||
{
|
||||
startTime: { lt: new Date(endTime) },
|
||||
endTime: { gte: new Date(endTime) },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { driverId: true },
|
||||
});
|
||||
|
||||
const busyIds = new Set(busyDriverIds.map((e) => e.driverId));
|
||||
|
||||
const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: availableDrivers,
|
||||
message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDriver(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { driverId, ...updates } = input;
|
||||
|
||||
const existingDriver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!existingDriver) {
|
||||
return { success: false, error: `Driver with ID ${driverId} not found.` };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.name !== undefined) updateData.name = updates.name;
|
||||
if (updates.phone !== undefined) updateData.phone = updates.phone;
|
||||
if (updates.department !== undefined) updateData.department = updates.department;
|
||||
if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable;
|
||||
if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime;
|
||||
if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime;
|
||||
|
||||
const driver = await this.prisma.driver.update({
|
||||
where: { id: driverId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Driver updated: ${driverId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: driver,
|
||||
message: `Driver ${driver.name} updated successfully.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
304
backend/src/copilot/copilot-reports.service.ts
Normal file
304
backend/src/copilot/copilot-reports.service.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { toDateString, startOfDay } from '../common/utils/date.utils';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotReportsService {
|
||||
private readonly logger = new Logger(CopilotReportsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getDriverWorkloadSummary(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { startDate, endDate } = input;
|
||||
|
||||
const dateStart = startOfDay(new Date(startDate));
|
||||
const dateEnd = new Date(endDate);
|
||||
dateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Get all drivers
|
||||
const drivers = await this.prisma.driver.findMany({
|
||||
where: { deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Get all events in range
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: dateStart, lte: dateEnd },
|
||||
status: { not: 'CANCELLED' },
|
||||
driverId: { not: null },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate workload for each driver
|
||||
const workloadData = drivers.map((driver) => {
|
||||
const driverEvents = events.filter((e) => e.driverId === driver.id);
|
||||
|
||||
const totalHours =
|
||||
driverEvents.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
const totalDays = Math.ceil(
|
||||
(dateEnd.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
const eventsByType = driverEvents.reduce(
|
||||
(acc, e) => {
|
||||
acc[e.type] = (acc[e.type] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return {
|
||||
driverId: driver.id,
|
||||
driverName: driver.name,
|
||||
department: driver.department,
|
||||
isAvailable: driver.isAvailable,
|
||||
eventCount: driverEvents.length,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
averageHoursPerDay: Math.round((totalHours / totalDays) * 10) / 10,
|
||||
eventsByType,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by total hours descending
|
||||
workloadData.sort((a, b) => b.totalHours - a.totalHours);
|
||||
|
||||
const totalEvents = events.length;
|
||||
const totalHours =
|
||||
events.reduce((sum, e) => {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}, 0) / 3600000;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
dateRange: {
|
||||
start: toDateString(dateStart),
|
||||
end: toDateString(dateEnd),
|
||||
},
|
||||
summary: {
|
||||
totalDrivers: drivers.length,
|
||||
totalEvents,
|
||||
totalHours: Math.round(totalHours * 10) / 10,
|
||||
averageEventsPerDriver: Math.round((totalEvents / drivers.length) * 10) / 10,
|
||||
},
|
||||
driverWorkloads: workloadData,
|
||||
},
|
||||
message: `Workload summary for ${drivers.length} driver(s) from ${toDateString(dateStart)} to ${toDateString(dateEnd)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentSystemStatus(): Promise<ToolResult> {
|
||||
const now = new Date();
|
||||
const today = startOfDay(now);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
const [
|
||||
vipCount,
|
||||
vehicleCount,
|
||||
driverCount,
|
||||
todaysEvents,
|
||||
upcomingEvents,
|
||||
unassignedEvents,
|
||||
availableDrivers,
|
||||
availableVehicles,
|
||||
] = await Promise.all([
|
||||
this.prisma.vIP.count({ where: { deletedAt: null } }),
|
||||
this.prisma.vehicle.count({ where: { deletedAt: null } }),
|
||||
this.prisma.driver.count({ where: { deletedAt: null } }),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: today, lt: tomorrow },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
}),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: tomorrow, lt: nextWeek },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
}),
|
||||
this.prisma.scheduleEvent.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: now },
|
||||
status: { in: ['SCHEDULED'] },
|
||||
OR: [{ driverId: null }, { vehicleId: null }],
|
||||
},
|
||||
}),
|
||||
this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }),
|
||||
this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }),
|
||||
]);
|
||||
|
||||
const status = {
|
||||
timestamp: now.toISOString(),
|
||||
resources: {
|
||||
vips: vipCount,
|
||||
drivers: { total: driverCount, available: availableDrivers },
|
||||
vehicles: { total: vehicleCount, available: availableVehicles },
|
||||
},
|
||||
events: {
|
||||
today: todaysEvents,
|
||||
next7Days: upcomingEvents,
|
||||
needingAttention: unassignedEvents,
|
||||
},
|
||||
alerts: [] as string[],
|
||||
};
|
||||
|
||||
// Add alerts for issues
|
||||
if (unassignedEvents > 0) {
|
||||
status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`);
|
||||
}
|
||||
if (availableDrivers === 0) {
|
||||
status.alerts.push('No drivers currently marked as available');
|
||||
}
|
||||
if (availableVehicles === 0) {
|
||||
status.alerts.push('No vehicles currently available');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
message:
|
||||
status.alerts.length > 0
|
||||
? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.`
|
||||
: 'System status retrieved. No immediate issues.',
|
||||
};
|
||||
}
|
||||
|
||||
async getTodaysSummary(): Promise<ToolResult> {
|
||||
const today = startOfDay(new Date());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Get today's events
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
startTime: { gte: today, lt: tomorrow },
|
||||
status: { not: 'CANCELLED' },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Fetch VIP names for all events
|
||||
const allVipIds = events.flatMap((e) => e.vipIds);
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: uniqueVipIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
|
||||
|
||||
// Get VIPs arriving today (flights or self-driving)
|
||||
const arrivingVips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{
|
||||
expectedArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
{
|
||||
flights: {
|
||||
some: {
|
||||
scheduledArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
flights: {
|
||||
where: {
|
||||
scheduledArrival: { gte: today, lt: tomorrow },
|
||||
},
|
||||
orderBy: { scheduledArrival: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get driver assignments
|
||||
const driversOnDuty = events
|
||||
.filter((e) => e.driver)
|
||||
.reduce((acc, e) => {
|
||||
if (e.driver && !acc.find((d) => d.id === e.driver!.id)) {
|
||||
acc.push(e.driver);
|
||||
}
|
||||
return acc;
|
||||
}, [] as NonNullable<typeof events[0]['driver']>[]);
|
||||
|
||||
// Unassigned events
|
||||
const unassigned = events.filter((e) => !e.driverId || !e.vehicleId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
date: toDateString(today),
|
||||
summary: {
|
||||
totalEvents: events.length,
|
||||
arrivingVips: arrivingVips.length,
|
||||
driversOnDuty: driversOnDuty.length,
|
||||
unassignedEvents: unassigned.length,
|
||||
},
|
||||
events: events.map((e) => ({
|
||||
id: e.id,
|
||||
time: e.startTime,
|
||||
title: e.title,
|
||||
type: e.type,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
driverName: e.driver?.name || 'UNASSIGNED',
|
||||
vehicleName: e.vehicle?.name || 'UNASSIGNED',
|
||||
location: e.location || e.pickupLocation,
|
||||
})),
|
||||
arrivingVips: arrivingVips.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
arrivalMode: v.arrivalMode,
|
||||
expectedArrival: v.expectedArrival,
|
||||
flights: v.flights.map((f) => ({
|
||||
flightNumber: f.flightNumber,
|
||||
scheduledArrival: f.scheduledArrival,
|
||||
arrivalAirport: f.arrivalAirport,
|
||||
})),
|
||||
})),
|
||||
driversOnDuty: driversOnDuty.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
eventCount: events.filter((e) => e.driverId === d.id).length,
|
||||
})),
|
||||
unassignedEvents: unassigned.map((e) => ({
|
||||
id: e.id,
|
||||
time: e.startTime,
|
||||
title: e.title,
|
||||
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
|
||||
needsDriver: !e.driverId,
|
||||
needsVehicle: !e.vehicleId,
|
||||
})),
|
||||
},
|
||||
message: `Today's summary: ${events.length} event(s), ${arrivingVips.length} VIP(s) arriving, ${unassigned.length} unassigned.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
1282
backend/src/copilot/copilot-schedule.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
275
backend/src/copilot/copilot-vip.service.ts
Normal file
275
backend/src/copilot/copilot-vip.service.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface ToolResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotVipService {
|
||||
private readonly logger = new Logger(CopilotVipService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async searchVips(filters: Record<string, any>): Promise<ToolResult> {
|
||||
const where: any = { deletedAt: null };
|
||||
|
||||
if (filters.name) {
|
||||
where.name = { contains: filters.name, mode: 'insensitive' };
|
||||
}
|
||||
if (filters.organization) {
|
||||
where.organization = { contains: filters.organization, mode: 'insensitive' };
|
||||
}
|
||||
if (filters.department) {
|
||||
where.department = filters.department;
|
||||
}
|
||||
if (filters.arrivalMode) {
|
||||
where.arrivalMode = filters.arrivalMode;
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where,
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Fetch events for these VIPs
|
||||
const vipIds = vips.map((v) => v.id);
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vipIds: { hasSome: vipIds },
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Attach events to VIPs
|
||||
const vipsWithEvents = vips.map((vip) => ({
|
||||
...vip,
|
||||
events: events.filter((e) => e.vipIds.includes(vip.id)).slice(0, 5),
|
||||
}));
|
||||
|
||||
return { success: true, data: vipsWithEvents };
|
||||
}
|
||||
|
||||
async getVipDetails(vipId: string): Promise<ToolResult> {
|
||||
const vip = await this.prisma.vIP.findUnique({
|
||||
where: { id: vipId },
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!vip) {
|
||||
return { success: false, error: 'VIP not found' };
|
||||
}
|
||||
|
||||
// Fetch events for this VIP
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
vipIds: { has: vipId },
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
return { success: true, data: { ...vip, events } };
|
||||
}
|
||||
|
||||
async createVip(input: Record<string, any>): Promise<ToolResult> {
|
||||
const vip = await this.prisma.vIP.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
organization: input.organization,
|
||||
department: input.department,
|
||||
arrivalMode: input.arrivalMode,
|
||||
expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null,
|
||||
airportPickup: input.airportPickup ?? false,
|
||||
venueTransport: input.venueTransport ?? false,
|
||||
partySize: input.partySize ?? 1,
|
||||
notes: input.notes,
|
||||
isRosterOnly: input.isRosterOnly ?? false,
|
||||
phone: input.phone || null,
|
||||
email: input.email || null,
|
||||
emergencyContactName: input.emergencyContactName || null,
|
||||
emergencyContactPhone: input.emergencyContactPhone || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: vip };
|
||||
}
|
||||
|
||||
async updateVip(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vipId, ...updateData } = input;
|
||||
|
||||
const data: any = {};
|
||||
if (updateData.name !== undefined) data.name = updateData.name;
|
||||
if (updateData.organization !== undefined) data.organization = updateData.organization;
|
||||
if (updateData.department !== undefined) data.department = updateData.department;
|
||||
if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode;
|
||||
if (updateData.expectedArrival !== undefined)
|
||||
data.expectedArrival = updateData.expectedArrival
|
||||
? new Date(updateData.expectedArrival)
|
||||
: null;
|
||||
if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup;
|
||||
if (updateData.venueTransport !== undefined)
|
||||
data.venueTransport = updateData.venueTransport;
|
||||
if (updateData.partySize !== undefined) data.partySize = updateData.partySize;
|
||||
if (updateData.notes !== undefined) data.notes = updateData.notes;
|
||||
if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly;
|
||||
if (updateData.phone !== undefined) data.phone = updateData.phone || null;
|
||||
if (updateData.email !== undefined) data.email = updateData.email || null;
|
||||
if (updateData.emergencyContactName !== undefined)
|
||||
data.emergencyContactName = updateData.emergencyContactName || null;
|
||||
if (updateData.emergencyContactPhone !== undefined)
|
||||
data.emergencyContactPhone = updateData.emergencyContactPhone || null;
|
||||
|
||||
const vip = await this.prisma.vIP.update({
|
||||
where: { id: vipId },
|
||||
data,
|
||||
include: { flights: true },
|
||||
});
|
||||
|
||||
return { success: true, data: vip };
|
||||
}
|
||||
|
||||
async getVipItinerary(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { vipId, startDate, endDate } = input;
|
||||
|
||||
const vip = await this.prisma.vIP.findUnique({
|
||||
where: { id: vipId },
|
||||
});
|
||||
|
||||
if (!vip) {
|
||||
return { success: false, error: 'VIP not found' };
|
||||
}
|
||||
|
||||
// Build date filters
|
||||
const dateFilter: any = {};
|
||||
if (startDate) dateFilter.gte = new Date(startDate);
|
||||
if (endDate) dateFilter.lte = new Date(endDate);
|
||||
|
||||
// Get flights
|
||||
const flightsWhere: any = { vipId };
|
||||
if (startDate || endDate) {
|
||||
flightsWhere.flightDate = dateFilter;
|
||||
}
|
||||
const flights = await this.prisma.flight.findMany({
|
||||
where: flightsWhere,
|
||||
orderBy: { scheduledDeparture: 'asc' },
|
||||
});
|
||||
|
||||
// Get events
|
||||
const eventsWhere: any = {
|
||||
deletedAt: null,
|
||||
vipIds: { has: vipId },
|
||||
};
|
||||
if (startDate || endDate) {
|
||||
eventsWhere.startTime = dateFilter;
|
||||
}
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: eventsWhere,
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
|
||||
// Combine and sort chronologically
|
||||
const itineraryItems: any[] = [
|
||||
...flights.map((f) => ({
|
||||
type: 'FLIGHT',
|
||||
time: f.scheduledDeparture || f.flightDate,
|
||||
data: f,
|
||||
})),
|
||||
...events.map((e) => ({
|
||||
type: 'EVENT',
|
||||
time: e.startTime,
|
||||
data: e,
|
||||
})),
|
||||
].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
vip,
|
||||
itinerary: itineraryItems,
|
||||
summary: {
|
||||
totalFlights: flights.length,
|
||||
totalEvents: events.length,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getFlightsForVip(vipId: string): Promise<ToolResult> {
|
||||
const flights = await this.prisma.flight.findMany({
|
||||
where: { vipId },
|
||||
orderBy: { flightDate: 'asc' },
|
||||
});
|
||||
|
||||
return { success: true, data: flights };
|
||||
}
|
||||
|
||||
async createFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||
const flight = await this.prisma.flight.create({
|
||||
data: {
|
||||
vipId: input.vipId,
|
||||
flightNumber: input.flightNumber,
|
||||
flightDate: new Date(input.flightDate),
|
||||
departureAirport: input.departureAirport,
|
||||
arrivalAirport: input.arrivalAirport,
|
||||
scheduledDeparture: input.scheduledDeparture
|
||||
? new Date(input.scheduledDeparture)
|
||||
: null,
|
||||
scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null,
|
||||
segment: input.segment || 1,
|
||||
},
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
return { success: true, data: flight };
|
||||
}
|
||||
|
||||
async updateFlight(input: Record<string, any>): Promise<ToolResult> {
|
||||
const { flightId, ...updateData } = input;
|
||||
|
||||
const flight = await this.prisma.flight.update({
|
||||
where: { id: flightId },
|
||||
data: updateData,
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
return { success: true, data: flight };
|
||||
}
|
||||
|
||||
async deleteFlight(flightId: string): Promise<ToolResult> {
|
||||
const flight = await this.prisma.flight.findUnique({
|
||||
where: { id: flightId },
|
||||
include: { vip: true },
|
||||
});
|
||||
|
||||
if (!flight) {
|
||||
return { success: false, error: 'Flight not found' };
|
||||
}
|
||||
|
||||
await this.prisma.flight.delete({
|
||||
where: { id: flightId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: true, flight },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CopilotController } from './copilot.controller';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { CopilotVipService } from './copilot-vip.service';
|
||||
import { CopilotScheduleService } from './copilot-schedule.service';
|
||||
import { CopilotFleetService } from './copilot-fleet.service';
|
||||
import { CopilotReportsService } from './copilot-reports.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
@@ -8,6 +12,12 @@ import { DriversModule } from '../drivers/drivers.module';
|
||||
@Module({
|
||||
imports: [PrismaModule, SignalModule, DriversModule],
|
||||
controllers: [CopilotController],
|
||||
providers: [CopilotService],
|
||||
providers: [
|
||||
CopilotService,
|
||||
CopilotVipService,
|
||||
CopilotScheduleService,
|
||||
CopilotFleetService,
|
||||
CopilotReportsService,
|
||||
],
|
||||
})
|
||||
export class CopilotModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ export class DriversService {
|
||||
private readonly driverInclude = {
|
||||
user: true,
|
||||
events: {
|
||||
where: { deletedAt: null },
|
||||
include: { vehicle: true, driver: true },
|
||||
orderBy: { startTime: 'asc' as const },
|
||||
},
|
||||
@@ -29,7 +28,6 @@ export class DriversService {
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.driver.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: this.driverInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
@@ -37,7 +35,7 @@ export class DriversService {
|
||||
|
||||
async findOne(id: string) {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
where: { id },
|
||||
include: this.driverInclude,
|
||||
});
|
||||
|
||||
@@ -50,7 +48,7 @@ export class DriversService {
|
||||
|
||||
async findByUserId(userId: string) {
|
||||
return this.prisma.driver.findFirst({
|
||||
where: { userId, deletedAt: null },
|
||||
where: { userId },
|
||||
include: this.driverInclude,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export class ScheduleExportService {
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
driverId,
|
||||
deletedAt: null,
|
||||
startTime: {
|
||||
gte: dayStart,
|
||||
lte: endOfDay,
|
||||
@@ -76,7 +75,6 @@ export class ScheduleExportService {
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
driverId,
|
||||
deletedAt: null,
|
||||
endTime: {
|
||||
gte: now, // Include events that haven't ended yet
|
||||
},
|
||||
@@ -133,7 +131,7 @@ export class ScheduleExportService {
|
||||
*/
|
||||
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
where: { id: driverId },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
@@ -210,7 +208,7 @@ export class ScheduleExportService {
|
||||
*/
|
||||
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
where: { id: driverId },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
@@ -357,7 +355,7 @@ export class ScheduleExportService {
|
||||
fullSchedule = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
where: { id: driverId },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
|
||||
@@ -86,7 +86,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||
reminder20MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
@@ -110,7 +109,6 @@ export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||
reminder5MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
@@ -218,7 +216,6 @@ Reply:
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
startTime: { lte: now },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
@@ -264,7 +261,6 @@ Reply:
|
||||
where: {
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
endTime: { lte: gracePeriodAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
@@ -347,7 +343,6 @@ Reply with 1, 2, or 3`;
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: {
|
||||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -360,7 +355,6 @@ Reply with 1, 2, or 3`;
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { vehicle: true },
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export class EventsService {
|
||||
select: { id: true, title: true, type: true, startTime: true, endTime: true },
|
||||
},
|
||||
childEvents: {
|
||||
where: { deletedAt: null },
|
||||
select: { id: true, title: true, type: true },
|
||||
},
|
||||
} as const;
|
||||
@@ -35,7 +34,6 @@ export class EventsService {
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
id: { in: createEventDto.vipIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,7 +88,6 @@ export class EventsService {
|
||||
|
||||
async findAll() {
|
||||
const events = await this.prisma.scheduleEvent.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: this.eventInclude,
|
||||
orderBy: { startTime: 'asc' },
|
||||
});
|
||||
@@ -107,7 +104,6 @@ export class EventsService {
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
id: { in: Array.from(allVipIds) },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
vips.forEach((vip) => vipsMap.set(vip.id, vip));
|
||||
@@ -129,7 +125,7 @@ export class EventsService {
|
||||
|
||||
async findOne(id: string) {
|
||||
const event = await this.prisma.scheduleEvent.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
where: { id },
|
||||
include: this.eventInclude,
|
||||
});
|
||||
|
||||
@@ -148,7 +144,6 @@ export class EventsService {
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
id: { in: updateEventDto.vipIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -264,7 +259,7 @@ export class EventsService {
|
||||
*/
|
||||
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
|
||||
const vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id: vehicleId, deletedAt: null },
|
||||
where: { id: vehicleId },
|
||||
});
|
||||
|
||||
if (!vehicle) {
|
||||
@@ -272,7 +267,7 @@ export class EventsService {
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: vipIds }, deletedAt: null },
|
||||
where: { id: { in: vipIds } },
|
||||
select: { partySize: true },
|
||||
});
|
||||
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
|
||||
@@ -302,7 +297,6 @@ export class EventsService {
|
||||
return this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
driverId,
|
||||
deletedAt: null,
|
||||
id: excludeEventId ? { not: excludeEventId } : undefined,
|
||||
OR: [
|
||||
{
|
||||
@@ -343,7 +337,6 @@ export class EventsService {
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: {
|
||||
id: { in: event.vipIds },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -375,9 +375,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
||||
const devices = await this.prisma.gpsDevice.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
driver: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
driver: {
|
||||
@@ -743,7 +740,6 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
||||
where: {
|
||||
role: 'ADMINISTRATOR',
|
||||
isApproved: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Models that have soft delete (deletedAt field)
|
||||
const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle'];
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
@@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
super({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// Apply soft-delete middleware
|
||||
this.applySoftDeleteMiddleware();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.$connect();
|
||||
this.logger.log('✅ Database connected successfully');
|
||||
this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', '));
|
||||
} catch (error) {
|
||||
this.logger.error('❌ Database connection failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Prisma middleware to automatically filter out soft-deleted records
|
||||
*
|
||||
* This middleware automatically adds `deletedAt: null` to where clauses for models
|
||||
* that have a deletedAt field, preventing soft-deleted records from being returned.
|
||||
*
|
||||
* Escape hatches:
|
||||
* - Pass `{ deletedAt: { not: null } }` to query ONLY deleted records
|
||||
* - Pass `{ deletedAt: undefined }` or any explicit deletedAt filter to bypass middleware
|
||||
* - Hard delete operations (delete, deleteMany) are not affected
|
||||
*/
|
||||
private applySoftDeleteMiddleware() {
|
||||
this.$use(async (params, next) => {
|
||||
// Only apply to models with soft delete
|
||||
if (!SOFT_DELETE_MODELS.includes(params.model || '')) {
|
||||
return next(params);
|
||||
}
|
||||
|
||||
// Operations to apply soft-delete filter to
|
||||
const operations = ['findUnique', 'findFirst', 'findMany', 'count', 'aggregate'];
|
||||
|
||||
if (operations.includes(params.action)) {
|
||||
// Initialize where clause if it doesn't exist
|
||||
params.args.where = params.args.where || {};
|
||||
|
||||
// Only apply filter if deletedAt is not already specified
|
||||
// This allows explicit queries for deleted records: { deletedAt: { not: null } }
|
||||
// or to bypass middleware: { deletedAt: undefined }
|
||||
if (!('deletedAt' in params.args.where)) {
|
||||
params.args.where.deletedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// For update/updateMany, ensure we don't accidentally update soft-deleted records
|
||||
if (params.action === 'update' || params.action === 'updateMany') {
|
||||
params.args.where = params.args.where || {};
|
||||
|
||||
// Only apply if not explicitly specified
|
||||
if (!('deletedAt' in params.args.where)) {
|
||||
params.args.where.deletedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return next(params);
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
this.logger.log('Database disconnected');
|
||||
|
||||
@@ -36,7 +36,7 @@ export class MessagesService {
|
||||
*/
|
||||
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
where: { id: driverId },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
@@ -55,7 +55,7 @@ export class MessagesService {
|
||||
*/
|
||||
async sendMessage(dto: SendMessageDto) {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: dto.driverId, deletedAt: null },
|
||||
where: { id: dto.driverId },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
@@ -113,7 +113,6 @@ export class MessagesService {
|
||||
// Find driver by phone number
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ phone: fromNumber },
|
||||
{ phone: normalizedPhone },
|
||||
@@ -172,7 +171,6 @@ export class MessagesService {
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { vehicle: true },
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ export class UsersService {
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { driver: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -19,7 +18,7 @@ export class UsersService {
|
||||
|
||||
async findOne(id: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
where: { id },
|
||||
include: { driver: true },
|
||||
});
|
||||
|
||||
@@ -136,7 +135,6 @@ export class UsersService {
|
||||
async getPendingUsers() {
|
||||
return this.prisma.user.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
isApproved: false,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
|
||||
@@ -10,7 +10,6 @@ export class VehiclesService {
|
||||
private readonly vehicleInclude = {
|
||||
currentDriver: true,
|
||||
events: {
|
||||
where: { deletedAt: null },
|
||||
include: { driver: true, vehicle: true },
|
||||
orderBy: { startTime: 'asc' as const },
|
||||
},
|
||||
@@ -29,7 +28,6 @@ export class VehiclesService {
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.vehicle.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: this.vehicleInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
@@ -38,7 +36,6 @@ export class VehiclesService {
|
||||
async findAvailable() {
|
||||
return this.prisma.vehicle.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'AVAILABLE',
|
||||
},
|
||||
include: {
|
||||
@@ -50,7 +47,7 @@ export class VehiclesService {
|
||||
|
||||
async findOne(id: string) {
|
||||
const vehicle = await this.prisma.vehicle.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
where: { id },
|
||||
include: this.vehicleInclude,
|
||||
});
|
||||
|
||||
@@ -98,12 +95,10 @@ export class VehiclesService {
|
||||
|
||||
// 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 },
|
||||
|
||||
@@ -22,7 +22,6 @@ export class VipsService {
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.vIP.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
@@ -32,7 +31,7 @@ export class VipsService {
|
||||
|
||||
async findOne(id: string) {
|
||||
const vip = await this.prisma.vIP.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
where: { id },
|
||||
include: {
|
||||
flights: true,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callback } from '@/pages/Callback';
|
||||
import { PendingApproval } from '@/pages/PendingApproval';
|
||||
import { Dashboard } from '@/pages/Dashboard';
|
||||
import { CommandCenter } from '@/pages/CommandCenter';
|
||||
import { VIPList } from '@/pages/VipList';
|
||||
import { VIPList } from '@/pages/VIPList';
|
||||
import { VIPSchedule } from '@/pages/VIPSchedule';
|
||||
import { FleetPage } from '@/pages/FleetPage';
|
||||
import { EventList } from '@/pages/EventList';
|
||||
|
||||
36
frontend/src/components/SortableHeader.tsx
Normal file
36
frontend/src/components/SortableHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
interface SortableHeaderProps<T extends string> {
|
||||
column: T;
|
||||
label: string;
|
||||
currentSort: {
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
onSort: (key: T) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SortableHeader<T extends string>({ column, label, currentSort, onSort, className = '' }: SortableHeaderProps<T>) {
|
||||
const isActive = currentSort.key === column;
|
||||
|
||||
return (
|
||||
<th
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors select-none ${className}`}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentSort.direction === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4 text-primary" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
55
frontend/src/hooks/useListPage.ts
Normal file
55
frontend/src/hooks/useListPage.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
interface UseListPageOptions<T extends string> {
|
||||
defaultSortKey: T;
|
||||
defaultSortDirection?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export function useListPage<T extends string>(options: UseListPageOptions<T>) {
|
||||
const { defaultSortKey, defaultSortDirection = 'asc' } = options;
|
||||
|
||||
// Search state
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
// Sort state
|
||||
const [sortKey, setSortKey] = useState<T>(defaultSortKey);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortDirection);
|
||||
|
||||
// Generic filter state (key-value pairs)
|
||||
const [filters, setFiltersState] = useState<Record<string, any>>({});
|
||||
|
||||
const handleSort = (key: T) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const setFilter = (key: string, value: any) => {
|
||||
setFiltersState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('');
|
||||
setFiltersState({});
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { Driver } from '@/types';
|
||||
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Search, X, Filter, Send, Eye } from 'lucide-react';
|
||||
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
||||
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
||||
import { FilterModal } from '@/components/FilterModal';
|
||||
import { FilterChip } from '@/components/FilterChip';
|
||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { SortableHeader } from '@/components/SortableHeader';
|
||||
import { useListPage } from '@/hooks/useListPage';
|
||||
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
||||
import { DriverChatModal } from '@/components/DriverChatModal';
|
||||
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
||||
@@ -22,8 +23,19 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Search and filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// List page state management
|
||||
const {
|
||||
search: searchTerm,
|
||||
setSearch: setSearchTerm,
|
||||
debouncedSearch: debouncedSearchTerm,
|
||||
sortKey: sortColumn,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
} = useListPage<'name' | 'phone' | 'department'>({
|
||||
defaultSortKey: 'name',
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
|
||||
@@ -36,13 +48,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||
// 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');
|
||||
|
||||
// Debounce search term
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Fetch unread message counts
|
||||
const { data: unreadCounts } = useUnreadCounts();
|
||||
|
||||
@@ -184,15 +189,6 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||
setSelectedDepartments([]);
|
||||
};
|
||||
|
||||
const handleSort = (column: typeof sortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDepartmentFilter = (dept: string) => {
|
||||
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
||||
};
|
||||
@@ -359,36 +355,24 @@ export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Name
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('phone')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Phone
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'phone' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('department')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Department
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<SortableHeader
|
||||
column="name"
|
||||
label="Name"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
column="phone"
|
||||
label="Phone"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
column="department"
|
||||
label="Department"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Assigned Events
|
||||
</th>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { ScheduleEvent, EventType } from '@/types';
|
||||
import { Plus, Edit, Trash2, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
||||
import { EventForm, EventFormData } from '@/components/EventForm';
|
||||
import { Loading } from '@/components/Loading';
|
||||
import { InlineDriverSelector } from '@/components/InlineDriverSelector';
|
||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||
import { SortableHeader } from '@/components/SortableHeader';
|
||||
import { useFormattedDate } from '@/hooks/useFormattedDate';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
|
||||
@@ -26,12 +27,14 @@ export function EventList() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<ActivityFilter>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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);
|
||||
|
||||
// Sort state (inline instead of useListPage since search is not debounced here)
|
||||
const [sortField, setSortField] = useState<SortField>('startTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const { data: events, isLoading } = useQuery<ScheduleEvent[]>({
|
||||
queryKey: queryKeys.events.all,
|
||||
queryFn: async () => {
|
||||
@@ -322,77 +325,47 @@ export function EventList() {
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Title
|
||||
{sortField === 'title' ? (
|
||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||
onClick={() => handleSort('type')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Type
|
||||
{sortField === 'type' ? (
|
||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||
onClick={() => handleSort('vips')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
VIPs
|
||||
{sortField === 'vips' ? (
|
||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<SortableHeader
|
||||
column="title"
|
||||
label="Title"
|
||||
currentSort={{ key: sortField, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
className="gap-1"
|
||||
/>
|
||||
<SortableHeader
|
||||
column="type"
|
||||
label="Type"
|
||||
currentSort={{ key: sortField, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
className="gap-1"
|
||||
/>
|
||||
<SortableHeader
|
||||
column="vips"
|
||||
label="VIPs"
|
||||
currentSort={{ key: sortField, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
className="gap-1"
|
||||
/>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Vehicle
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Driver
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||
onClick={() => handleSort('startTime')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Start Time
|
||||
{sortField === 'startTime' ? (
|
||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Status
|
||||
{sortField === 'status' ? (
|
||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<SortableHeader
|
||||
column="startTime"
|
||||
label="Start Time"
|
||||
currentSort={{ key: sortField, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
className="gap-1"
|
||||
/>
|
||||
<SortableHeader
|
||||
column="status"
|
||||
label="Status"
|
||||
currentSort={{ key: sortField, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
className="gap-1"
|
||||
/>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||
Actions
|
||||
</th>
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useNavigate } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { VIP } from '@/types';
|
||||
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ArrowUpDown, ClipboardList } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, Search, X, Calendar, Filter, ClipboardList } from 'lucide-react';
|
||||
import { VIPForm, VIPFormData } from '@/components/VIPForm';
|
||||
import { VIPCardSkeleton, TableSkeleton } from '@/components/Skeleton';
|
||||
import { FilterModal } from '@/components/FilterModal';
|
||||
import { FilterChip } from '@/components/FilterChip';
|
||||
import { ConfirmModal } from '@/components/ConfirmModal';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { SortableHeader } from '@/components/SortableHeader';
|
||||
import { useListPage } from '@/hooks/useListPage';
|
||||
import { DEPARTMENT_LABELS, ARRIVAL_MODE_LABELS } from '@/lib/enum-labels';
|
||||
|
||||
export function VIPList() {
|
||||
@@ -20,25 +21,29 @@ export function VIPList() {
|
||||
const [editingVIP, setEditingVIP] = useState<VIP | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Search and filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// List page state management
|
||||
const {
|
||||
search: searchTerm,
|
||||
setSearch: setSearchTerm,
|
||||
debouncedSearch: debouncedSearchTerm,
|
||||
sortKey: sortColumn,
|
||||
sortDirection,
|
||||
handleSort,
|
||||
} = useListPage<'name' | 'organization' | 'department' | 'arrivalMode'>({
|
||||
defaultSortKey: 'name',
|
||||
});
|
||||
|
||||
// Filter state
|
||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||
const [selectedArrivalModes, setSelectedArrivalModes] = useState<string[]>([]);
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||
|
||||
// Sort state
|
||||
const [sortColumn, setSortColumn] = useState<'name' | 'organization' | 'department' | 'arrivalMode'>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// Roster-only toggle (hidden by default)
|
||||
const [showRosterOnly, setShowRosterOnly] = useState(false);
|
||||
|
||||
// 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);
|
||||
|
||||
const { data: vips, isLoading } = useQuery<VIP[]>({
|
||||
queryKey: ['vips'],
|
||||
queryFn: async () => {
|
||||
@@ -175,15 +180,6 @@ export function VIPList() {
|
||||
setSelectedArrivalModes([]);
|
||||
};
|
||||
|
||||
const handleSort = (column: typeof sortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDepartmentFilter = (dept: string) => {
|
||||
setSelectedDepartments((prev) => prev.filter((d) => d !== dept));
|
||||
};
|
||||
@@ -363,46 +359,30 @@ export function VIPList() {
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Name
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'name' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('organization')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Organization
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'organization' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('department')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Department
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => handleSort('arrivalMode')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
Arrival Mode
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{sortColumn === 'arrivalMode' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||
</div>
|
||||
</th>
|
||||
<SortableHeader
|
||||
column="name"
|
||||
label="Name"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
column="organization"
|
||||
label="Organization"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
column="department"
|
||||
label="Department"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
column="arrivalMode"
|
||||
label="Arrival Mode"
|
||||
currentSort={{ key: sortColumn, direction: sortDirection }}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
|
||||
Reference in New Issue
Block a user