refactor: complete code efficiency pass (Issues #10, #14, #16)

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:
2026-02-08 16:34:18 +01:00
parent f2b3f34a72
commit 3bc9cd0bca
23 changed files with 2975 additions and 2443 deletions

View File

@@ -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 },
});
}

View 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.`,
};
}
}

View 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.`,
};
}
}

File diff suppressed because it is too large Load Diff

View 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 },
};
}
}

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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) {

View File

@@ -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 },
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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');

View File

@@ -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 },
});

View File

@@ -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' },

View File

@@ -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 },

View File

@@ -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,
},

View File

@@ -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';

View 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>
);
}

View 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,
};
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>