feat: add driver schedule self-service and full schedule support

This commit implements comprehensive driver schedule self-service functionality,
allowing drivers to access their own schedules without requiring administrator
permissions, along with full schedule support for multi-day views.

Backend Changes:
- Added /drivers/me/* endpoints for driver self-service operations:
  - GET /drivers/me - Get authenticated driver's profile
  - GET /drivers/me/schedule/ics - Export driver's own schedule as ICS
  - GET /drivers/me/schedule/pdf - Export driver's own schedule as PDF
  - POST /drivers/me/send-schedule - Send schedule to driver via Signal
  - PATCH /drivers/me - Update driver's own profile
- Added fullSchedule parameter support to schedule export service:
  - Defaults to true (full upcoming schedule)
  - Pass fullSchedule=false for single-day view
  - Applied to ICS, PDF, and Signal message generation
- Fixed route ordering in drivers.controller.ts:
  - Static routes (send-all-schedules) now come before :id routes
  - Prevents path matching issues
- TypeScript improvements in copilot.service.ts:
  - Fixed type errors with proper null handling
  - Added explicit return types

Frontend Changes:
- Created MySchedule page with simplified driver-focused UI:
  - Preview PDF button - Opens schedule PDF in new browser tab
  - Send to Signal button - Sends schedule directly to driver's phone
  - Uses /drivers/me/* endpoints to avoid permission issues
  - No longer requires driver ID parameter
- Resolved "Forbidden Resource" errors for driver role users:
  - Replaced /drivers/:id endpoints with /drivers/me endpoints
  - Drivers can now access their own data without admin permissions

Key Features:
1. Full Schedule by Default - Drivers see all upcoming events, not just today
2. Self-Service Access - Drivers manage their own schedules independently
3. PDF Preview - Quick browser-based preview without downloading
4. Signal Integration - Direct schedule delivery to mobile devices
5. Role-Based Security - Proper CASL permissions for driver self-access

This resolves the driver schedule access issue and provides a streamlined
experience for drivers to view and share their schedules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:27:13 +01:00
parent 374ffcfa12
commit 2d842ed294
4 changed files with 4107 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -8,18 +8,24 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { DriversService } from './drivers.service'; import { DriversService } from './drivers.service';
import { ScheduleExportService } from './schedule-export.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard'; import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import { CreateDriverDto, UpdateDriverDto } from './dto'; import { CreateDriverDto, UpdateDriverDto } from './dto';
@Controller('drivers') @Controller('drivers')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
export class DriversController { export class DriversController {
constructor(private readonly driversService: DriversService) {} constructor(
private readonly driversService: DriversService,
private readonly scheduleExportService: ScheduleExportService,
) {}
@Post() @Post()
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
@@ -33,6 +39,150 @@ export class DriversController {
return this.driversService.findAll(); return this.driversService.findAll();
} }
@Get('me')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyDriverProfile(@CurrentUser() user: any) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
return driver;
}
/**
* Get ICS calendar file for driver's own schedule
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Get('me/schedule/ics')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyScheduleICS(
@CurrentUser() user: any,
@Query('date') dateStr?: string,
@Query('fullSchedule') fullScheduleStr?: string,
) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
const date = dateStr ? new Date(dateStr) : new Date();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
: `schedule-${date.toISOString().split('T')[0]}.ics`;
return { ics: icsContent, filename };
}
/**
* Get PDF schedule for driver's own schedule
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Get('me/schedule/pdf')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMySchedulePDF(
@CurrentUser() user: any,
@Query('date') dateStr?: string,
@Query('fullSchedule') fullScheduleStr?: string,
) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
const date = dateStr ? new Date(dateStr) : new Date();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
return { pdf: pdfBuffer.toString('base64'), filename };
}
/**
* Send schedule to driver's own phone via Signal
* By default, sends full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Post('me/send-schedule')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async sendMySchedule(
@CurrentUser() user: any,
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = body.fullSchedule !== false;
return this.scheduleExportService.sendScheduleToDriver(driver.id, date, format, fullSchedule);
}
@Patch('me')
@Roles(Role.DRIVER)
async updateMyProfile(@CurrentUser() user: any, @Body() updateDriverDto: UpdateDriverDto) {
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
return this.driversService.update(driver.id, updateDriverDto);
}
/**
* Send schedule to all drivers with events on a given date
* NOTE: This static route MUST come before :id routes to avoid matching issues
*/
@Post('send-all-schedules')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
async sendAllSchedules(
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
) {
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
// Get all drivers with events on this date
const drivers = await this.driversService.findAll();
const results: Array<{ driverId: string; driverName: string; success: boolean; message: string }> = [];
for (const driver of drivers) {
try {
const result = await this.scheduleExportService.sendScheduleToDriver(
driver.id,
date,
format,
);
results.push({
driverId: driver.id,
driverName: driver.name,
success: result.success,
message: result.message,
});
} catch (error: any) {
// Skip drivers without events or phone numbers
if (!error.message?.includes('No events')) {
results.push({
driverId: driver.id,
driverName: driver.name,
success: false,
message: error.message,
});
}
}
}
const successCount = results.filter((r) => r.success).length;
return {
success: true,
sent: successCount,
total: results.length,
results,
};
}
// === Routes with :id parameter MUST come AFTER all static routes ===
@Get(':id') @Get(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR, Role.DRIVER) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR, Role.DRIVER)
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -45,6 +195,20 @@ export class DriversController {
return this.driversService.getSchedule(id); return this.driversService.getSchedule(id);
} }
/**
* Send schedule to driver via Signal (ICS and/or PDF)
*/
@Post(':id/send-schedule')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
async sendSchedule(
@Param('id') id: string,
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
) {
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
return this.scheduleExportService.sendScheduleToDriver(id, date, format);
}
@Patch(':id') @Patch(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR) @Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
update(@Param('id') id: string, @Body() updateDriverDto: UpdateDriverDto) { update(@Param('id') id: string, @Body() updateDriverDto: UpdateDriverDto) {

View File

@@ -0,0 +1,465 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service';
import * as ics from 'ics';
import * as PDFDocument from 'pdfkit';
interface ScheduleEventWithDetails {
id: string;
title: string;
startTime: Date;
endTime: Date;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
notes: string | null;
type: string;
status: string;
vipIds: string[];
vipNames: string[];
vehicle: { name: string; licensePlate: string | null } | null;
}
@Injectable()
export class ScheduleExportService {
private readonly logger = new Logger(ScheduleExportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly signalService: SignalService,
) {}
/**
* Get a driver's schedule for a specific date
*/
async getDriverSchedule(
driverId: string,
date: Date,
): Promise<ScheduleEventWithDetails[]> {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
startTime: {
gte: startOfDay,
lte: endOfDay,
},
status: {
not: 'CANCELLED',
},
},
include: {
vehicle: {
select: { name: true, licensePlate: true },
},
},
orderBy: { startTime: 'asc' },
});
return this.mapEventsWithVipNames(events);
}
/**
* Get a driver's full upcoming schedule (all future events)
*/
async getDriverFullSchedule(
driverId: string,
): Promise<ScheduleEventWithDetails[]> {
const now = new Date();
now.setHours(0, 0, 0, 0); // Start of today
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
endTime: {
gte: now, // Include events that haven't ended yet
},
status: {
not: 'CANCELLED',
},
},
include: {
vehicle: {
select: { name: true, licensePlate: true },
},
},
orderBy: { startTime: 'asc' },
});
return this.mapEventsWithVipNames(events);
}
/**
* Helper to map events with VIP names
*/
private async mapEventsWithVipNames(
events: any[],
): Promise<ScheduleEventWithDetails[]> {
// Fetch VIP names for all events
const allVipIds = [...new Set(events.flatMap((e) => e.vipIds))];
const vips = await this.prisma.vIP.findMany({
where: { id: { in: allVipIds } },
select: { id: true, name: true },
});
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
// Map events with VIP names
return events.map((event) => ({
id: event.id,
title: event.title,
startTime: event.startTime,
endTime: event.endTime,
pickupLocation: event.pickupLocation,
dropoffLocation: event.dropoffLocation,
location: event.location,
notes: event.notes,
type: event.type,
status: event.status,
vipIds: event.vipIds,
vipNames: event.vipIds.map((id: string) => vipMap.get(id) || 'Unknown VIP'),
vehicle: event.vehicle,
}));
}
/**
* Generate ICS calendar file for a driver's schedule
* @param fullSchedule If true, includes all upcoming events. If false, only the specified date.
*/
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date');
}
const icsEvents: ics.EventAttributes[] = events.map((event) => {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const vipNames = event.vipNames.join(', ');
const location =
event.pickupLocation && event.dropoffLocation
? `${event.pickupLocation}${event.dropoffLocation}`
: event.location || 'TBD';
let description = `VIP: ${vipNames}\n`;
if (event.vehicle) {
description += `Vehicle: ${event.vehicle.name}`;
if (event.vehicle.licensePlate) {
description += ` (${event.vehicle.licensePlate})`;
}
description += '\n';
}
if (event.notes) {
description += `Notes: ${event.notes}\n`;
}
return {
start: [
start.getFullYear(),
start.getMonth() + 1,
start.getDate(),
start.getHours(),
start.getMinutes(),
] as [number, number, number, number, number],
end: [
end.getFullYear(),
end.getMonth() + 1,
end.getDate(),
end.getHours(),
end.getMinutes(),
] as [number, number, number, number, number],
title: `${event.title} - ${vipNames}`,
description,
location,
status: 'CONFIRMED' as const,
busyStatus: 'BUSY' as const,
organizer: { name: 'VIP Coordinator', email: 'noreply@vipcoordinator.app' },
};
});
const { error, value } = ics.createEvents(icsEvents);
if (error) {
this.logger.error('Failed to generate ICS:', error);
throw new Error('Failed to generate calendar file');
}
return value || '';
}
/**
* Generate PDF schedule for a driver
* @param fullSchedule If true, includes all upcoming events. If false, only the specified date.
*/
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date');
}
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const dateStr = fullSchedule
? 'Full Upcoming Schedule'
: date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Header
doc
.fontSize(24)
.font('Helvetica-Bold')
.text('VIP Coordinator', { align: 'center' });
doc.moveDown(0.5);
doc
.fontSize(16)
.font('Helvetica')
.text(`Driver Schedule: ${driver.name}`, { align: 'center' });
doc.fontSize(12).text(dateStr, { align: 'center' });
doc.moveDown(1);
// Divider line
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.stroke();
doc.moveDown(1);
// Events
events.forEach((event, index) => {
const startTime = new Date(event.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
const endTime = new Date(event.endTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
const vipNames = event.vipNames.join(', ');
// Event header with time
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(`${startTime} - ${endTime}`, { continued: false });
// Event title
doc.fontSize(12).font('Helvetica-Bold').text(event.title);
// VIP
doc.fontSize(11).font('Helvetica').text(`VIP: ${vipNames}`);
// Location
if (event.pickupLocation && event.dropoffLocation) {
doc.text(`Pickup: ${event.pickupLocation}`);
doc.text(`Dropoff: ${event.dropoffLocation}`);
} else if (event.location) {
doc.text(`Location: ${event.location}`);
}
// Vehicle
if (event.vehicle) {
let vehicleText = `Vehicle: ${event.vehicle.name}`;
if (event.vehicle.licensePlate) {
vehicleText += ` (${event.vehicle.licensePlate})`;
}
doc.text(vehicleText);
}
// Notes
if (event.notes) {
doc
.fontSize(10)
.fillColor('#666666')
.text(`Notes: ${event.notes}`)
.fillColor('#000000');
}
// Status badge
doc
.fontSize(9)
.fillColor(event.status === 'COMPLETED' ? '#22c55e' : '#3b82f6')
.text(`Status: ${event.status}`)
.fillColor('#000000');
// Spacing between events
if (index < events.length - 1) {
doc.moveDown(0.5);
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.strokeColor('#cccccc')
.stroke()
.strokeColor('#000000');
doc.moveDown(0.5);
}
});
// Footer
doc.moveDown(2);
doc
.fontSize(9)
.fillColor('#999999')
.text(
`Generated on ${new Date().toLocaleString('en-US')} by VIP Coordinator`,
{ align: 'center' },
);
doc.end();
});
}
/**
* Send schedule to driver via Signal
* @param fullSchedule If true, sends all upcoming events. If false, only the specified date.
*/
async sendScheduleToDriver(
driverId: string,
date: Date,
format: 'ics' | 'pdf' | 'both' = 'both',
fullSchedule = false,
): Promise<{ success: boolean; message: string }> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
if (!driver.phone) {
throw new Error('Driver does not have a phone number configured');
}
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
throw new Error('No Signal account linked');
}
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
const dateStr = fullSchedule
? 'your full upcoming schedule'
: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const results: string[] = [];
// Send text message first
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
await this.signalService.sendMessage(
fromNumber,
toNumber,
fullSchedule ? 'No upcoming events scheduled.' : `No events scheduled for ${dateStr}.`,
);
return { success: true, message: 'No events to send' };
}
await this.signalService.sendMessage(
fromNumber,
toNumber,
`Your ${fullSchedule ? 'full upcoming' : ''} schedule${fullSchedule ? '' : ` for ${dateStr}`} (${events.length} event${events.length > 1 ? 's' : ''}):`,
);
// Send ICS
if (format === 'ics' || format === 'both') {
try {
const icsContent = await this.generateICS(driverId, date, fullSchedule);
const icsBase64 = Buffer.from(icsContent).toString('base64');
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.ics`
: `schedule-${date.toISOString().split('T')[0]}.ics`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
toNumber,
'Calendar file - add to your calendar app:',
icsBase64,
filename,
'text/calendar',
);
results.push('ICS');
this.logger.log(`ICS sent to driver ${driver.name}`);
} catch (error: any) {
this.logger.error(`Failed to send ICS: ${error.message}`);
}
}
// Send PDF
if (format === 'pdf' || format === 'both') {
try {
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
const pdfBase64 = pdfBuffer.toString('base64');
const filename = fullSchedule
? `full-schedule-${new Date().toISOString().split('T')[0]}.pdf`
: `schedule-${date.toISOString().split('T')[0]}.pdf`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
toNumber,
fullSchedule ? 'Full schedule PDF:' : 'PDF schedule:',
pdfBase64,
filename,
'application/pdf',
);
results.push('PDF');
this.logger.log(`PDF sent to driver ${driver.name}`);
} catch (error: any) {
this.logger.error(`Failed to send PDF: ${error.message}`);
}
}
if (results.length === 0) {
throw new Error('Failed to send any schedule files');
}
return {
success: true,
message: `Sent ${results.join(' and ')} schedule to ${driver.name}`,
};
}
}

View File

@@ -0,0 +1,325 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import toast from 'react-hot-toast';
import {
Calendar,
Clock,
MapPin,
Car,
User,
CheckCircle,
AlertCircle,
ArrowRight,
Send,
Eye,
} from 'lucide-react';
interface ScheduleEvent {
id: string;
title: string;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
startTime: string;
endTime: string;
status: string;
type: string;
vip: {
id: string;
name: string;
} | null;
vehicle: {
id: string;
name: string;
type: string;
} | null;
}
interface DriverWithSchedule {
id: string;
name: string;
phone: string | null;
events: ScheduleEvent[];
}
export function MySchedule() {
const { data: profile, isLoading, error } = useQuery<DriverWithSchedule>({
queryKey: ['my-driver-profile'],
queryFn: async () => {
const { data } = await api.get('/drivers/me');
return data;
},
});
// Send schedule via Signal - uses /me endpoint so drivers can call it
const sendScheduleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/drivers/me/send-schedule', { format: 'both' });
return data;
},
onSuccess: (data) => {
toast.success(data.message || 'Schedule sent to your phone');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to send schedule');
},
});
// Preview PDF - opens in new tab
const previewPDFMutation = useMutation({
mutationFn: async () => {
const { data } = await api.get('/drivers/me/schedule/pdf');
return data;
},
onSuccess: (data) => {
// Convert base64 to blob and open in new tab
const byteCharacters = atob(data.pdf);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
window.open(url, '_blank');
},
onError: (error: any) => {
const message = error.response?.data?.message || 'Failed to load PDF';
if (message.includes('No events') || message.includes('No upcoming')) {
toast.error('No upcoming events to preview');
} else {
toast.error(message);
}
},
});
if (isLoading) {
return <Loading message="Loading your schedule..." />;
}
if (error || !profile) {
return (
<div className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h2 className="text-xl font-semibold text-foreground mb-2">Schedule Not Found</h2>
<p className="text-muted-foreground">Unable to load your schedule.</p>
</div>
);
}
const now = new Date();
// Split events into upcoming and past
const upcomingEvents = profile.events
.filter((e) => new Date(e.endTime) >= now && e.status !== 'CANCELLED')
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
const activeEvents = upcomingEvents.filter((e) => e.status === 'IN_PROGRESS');
const scheduledEvents = upcomingEvents.filter((e) => e.status === 'SCHEDULED');
const pastEvents = profile.events
.filter((e) => new Date(e.endTime) < now || e.status === 'COMPLETED')
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
.slice(0, 5); // Last 5 completed
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
} else if (date.toDateString() === tomorrow.toDateString()) {
return 'Tomorrow';
}
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'IN_PROGRESS':
return (
<span className="px-2 py-0.5 bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full text-xs font-medium">
In Progress
</span>
);
case 'SCHEDULED':
return (
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full text-xs font-medium">
Scheduled
</span>
);
case 'COMPLETED':
return (
<span className="px-2 py-0.5 bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400 rounded-full text-xs font-medium">
Completed
</span>
);
default:
return null;
}
};
const EventCard = ({ event, isActive = false }: { event: ScheduleEvent; isActive?: boolean }) => (
<div
className={`bg-card border rounded-lg p-4 ${
isActive ? 'border-green-500 border-2 shadow-md' : 'border-border'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{getStatusBadge(event.status)}
<span className="text-sm text-muted-foreground">{event.type}</span>
</div>
{/* Event Title */}
<h3 className="font-bold text-foreground text-lg mb-2">{event.title}</h3>
{/* VIP Name */}
{event.vip && (
<div className="flex items-center gap-2 mb-2">
<User className="h-4 w-4 text-primary" />
<span className="font-semibold text-foreground">{event.vip.name}</span>
</div>
)}
{event.type === 'TRANSPORT' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<MapPin className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{event.pickupLocation || 'TBD'}</span>
<ArrowRight className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{event.dropoffLocation || 'TBD'}</span>
</div>
)}
{event.location && event.type !== 'TRANSPORT' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<MapPin className="h-4 w-4" />
<span>{event.location}</span>
</div>
)}
{event.vehicle && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Car className="h-4 w-4" />
<span>{event.vehicle.name}</span>
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="text-sm font-medium text-foreground">{formatDate(event.startTime)}</p>
<p className="text-lg font-bold text-primary">
{formatTime(event.startTime)}
</p>
<p className="text-xs text-muted-foreground">
to {formatTime(event.endTime)}
</p>
</div>
</div>
</div>
);
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Calendar className="h-6 w-6" />
My Schedule
</h1>
<p className="text-muted-foreground">Your upcoming trips and assignments</p>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => previewPDFMutation.mutate()}
disabled={previewPDFMutation.isPending}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-sm font-medium text-foreground bg-card hover:bg-accent transition-colors disabled:opacity-50"
style={{ minHeight: '44px' }}
title="Preview your full schedule as PDF"
>
<Eye className="h-4 w-4 mr-2" />
{previewPDFMutation.isPending ? 'Loading...' : 'Preview PDF'}
</button>
<button
onClick={() => sendScheduleMutation.mutate()}
disabled={sendScheduleMutation.isPending || !profile?.phone}
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-sm font-medium text-green-600 bg-card hover:bg-green-50 dark:hover:bg-green-950/20 transition-colors disabled:opacity-50"
style={{ minHeight: '44px' }}
title={!profile?.phone ? 'No phone number configured' : 'Send schedule to your phone via Signal'}
>
<Send className="h-4 w-4 mr-2" />
{sendScheduleMutation.isPending ? 'Sending...' : 'Send to Signal'}
</button>
</div>
</div>
{/* Active Now */}
{activeEvents.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
Active Now
</h2>
<div className="space-y-3">
{activeEvents.map((event) => (
<EventCard key={event.id} event={event} isActive />
))}
</div>
</div>
)}
{/* Upcoming */}
<div>
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
<Clock className="h-5 w-5" />
Upcoming
</h2>
{scheduledEvents.length === 0 ? (
<div className="bg-card border border-border rounded-lg p-8 text-center">
<Calendar className="h-12 w-12 mx-auto mb-3 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">No upcoming assignments</p>
</div>
) : (
<div className="space-y-3">
{scheduledEvents.map((event) => (
<EventCard key={event.id} event={event} />
))}
</div>
)}
</div>
{/* Recent Completed */}
{pastEvents.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-foreground mb-3 flex items-center gap-2">
<CheckCircle className="h-5 w-5" />
Recently Completed
</h2>
<div className="space-y-3 opacity-75">
{pastEvents.map((event) => (
<EventCard key={event.id} event={event} />
))}
</div>
</div>
)}
</div>
);
}