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:
325
frontend/src/pages/MySchedule.tsx
Normal file
325
frontend/src/pages/MySchedule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user