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:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

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