Files
vip-coordinator/frontend/src/components/DriverScheduleModal.tsx
kyle a4d360aae9 feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:36:51 +01:00

282 lines
10 KiB
TypeScript

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';
import { useFormattedDate } from '@/hooks/useFormattedDate';
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 { formatDate, formatTime } = useFormattedDate();
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 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>
);
}