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

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