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>
282 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|