feat: comprehensive update with Signal, Copilot, themes, and PDF features
## Signal Messaging Integration - Added SignalService for sending messages to drivers via Signal - SignalMessage model for tracking message history - Driver chat modal for real-time messaging - Send schedule via Signal (ICS + PDF attachments) ## AI Copilot - Natural language interface for VIP Coordinator - Capabilities: create VIPs, schedule events, assign drivers - Help and guidance for users - Floating copilot button in UI ## Theme System - Dark/light/system theme support - Color scheme selection (blue, green, purple, orange, red) - ThemeContext for global state - AppearanceMenu in header ## PDF Schedule Export - VIPSchedulePDF component for schedule generation - PDF settings (header, footer, branding) - Preview PDF in browser - Settings stored in database ## Database Migrations - add_signal_messages: SignalMessage model - add_pdf_settings: Settings model for PDF config - add_reminder_tracking: lastReminderSent for events - make_driver_phone_optional: phone field nullable ## Event Management - Event status service for automated updates - IN_PROGRESS/COMPLETED status tracking - Reminder tracking for notifications ## UI/UX Improvements - Driver schedule modal - Improved My Schedule page - Better error handling and loading states - Responsive design improvements ## Other Changes - AGENT_TEAM.md documentation - Seed data improvements - Ability factory updates - Driver profile page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
296
frontend/src/components/DriverScheduleModal.tsx
Normal file
296
frontend/src/components/DriverScheduleModal.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Driver } from '@/types';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ScheduleEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
pickupLocation?: string;
|
||||
dropoffLocation?: string;
|
||||
location?: string;
|
||||
status: string;
|
||||
type: string;
|
||||
vipIds: string[];
|
||||
vehicle?: {
|
||||
name: string;
|
||||
licensePlate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DriverScheduleResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
events: ScheduleEvent[];
|
||||
}
|
||||
|
||||
interface DriverScheduleModalProps {
|
||||
driver: Driver | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
|
||||
const dateString = selectedDate.toISOString().split('T')[0];
|
||||
|
||||
const { data: schedule, isLoading } = useQuery<DriverScheduleResponse>({
|
||||
queryKey: ['driver-schedule', driver?.id, dateString],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/drivers/${driver?.id}/schedule`, {
|
||||
params: { date: dateString },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
enabled: isOpen && !!driver?.id,
|
||||
});
|
||||
|
||||
// Fetch VIP names for the events
|
||||
const allVipIds = schedule?.events?.flatMap((e) => e.vipIds) || [];
|
||||
const uniqueVipIds = [...new Set(allVipIds)];
|
||||
|
||||
const { data: vips } = useQuery({
|
||||
queryKey: ['vips-for-schedule', uniqueVipIds.join(',')],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/vips');
|
||||
return data;
|
||||
},
|
||||
enabled: uniqueVipIds.length > 0,
|
||||
});
|
||||
|
||||
const vipMap = new Map(vips?.map((v: any) => [v.id, v.name]) || []);
|
||||
|
||||
if (!isOpen || !driver) return null;
|
||||
|
||||
const goToPreviousDay = () => {
|
||||
const newDate = new Date(selectedDate);
|
||||
newDate.setDate(newDate.getDate() - 1);
|
||||
setSelectedDate(newDate);
|
||||
};
|
||||
|
||||
const goToNextDay = () => {
|
||||
const newDate = new Date(selectedDate);
|
||||
newDate.setDate(newDate.getDate() + 1);
|
||||
setSelectedDate(newDate);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setSelectedDate(new Date());
|
||||
};
|
||||
|
||||
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'IN_PROGRESS':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'CANCELLED':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
TRANSPORT: 'Transport',
|
||||
MEETING: 'Meeting',
|
||||
EVENT: 'Event',
|
||||
MEAL: 'Meal',
|
||||
ACCOMMODATION: 'Accommodation',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// Filter events for the selected date
|
||||
const dayEvents = schedule?.events?.filter((event) => {
|
||||
const eventDate = new Date(event.startTime).toDateString();
|
||||
return eventDate === selectedDate.toDateString();
|
||||
}) || [];
|
||||
|
||||
// Sort events by start time
|
||||
const sortedEvents = [...dayEvents].sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div className="bg-card border border-border rounded-lg shadow-elevated w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
{driver.name}'s Schedule
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{driver.phone}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date Navigation */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-muted/30">
|
||||
<button
|
||||
onClick={goToPreviousDay}
|
||||
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-foreground">{formatDate(selectedDate)}</span>
|
||||
{!isToday && (
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={goToNextDay}
|
||||
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : sortedEvents.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||
<p className="text-muted-foreground">No events scheduled for this day</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedEvents.map((event) => {
|
||||
const vipNames = event.vipIds
|
||||
.map((id) => vipMap.get(id) || 'Unknown VIP')
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative pl-8 pb-4 border-l-2 border-border last:border-l-0"
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-primary border-4 border-card" />
|
||||
|
||||
<div className="bg-muted/30 border border-border rounded-lg p-4">
|
||||
{/* Time and Status */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-muted text-muted-foreground">
|
||||
{getTypeLabel(event.type)}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStatusColor(event.status)}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-foreground mb-2">{event.title}</h3>
|
||||
|
||||
{/* VIP */}
|
||||
{vipNames && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{vipNames}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{(event.pickupLocation || event.dropoffLocation || event.location) && (
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground mb-2">
|
||||
<MapPin className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
{event.pickupLocation && event.dropoffLocation ? (
|
||||
<>
|
||||
<div>{event.pickupLocation}</div>
|
||||
<div className="text-xs text-muted-foreground/70">→</div>
|
||||
<div>{event.dropoffLocation}</div>
|
||||
</>
|
||||
) : (
|
||||
<span>{event.location}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vehicle */}
|
||||
{event.vehicle && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Car className="h-4 w-4" />
|
||||
<span>
|
||||
{event.vehicle.name}
|
||||
{event.vehicle.licensePlate && ` (${event.vehicle.licensePlate})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border bg-muted/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{sortedEvents.length} event{sortedEvents.length !== 1 ? 's' : ''} scheduled
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user