feat: add GPS tracking with Traccar integration

- Add GPS module with Traccar client service for device management
- Add driver enrollment flow with QR code generation
- Add real-time location tracking on driver profiles
- Add GPS settings configuration in admin tools
- Add Auth0 OpenID Connect setup script for Traccar
- Add deployment configs for production server
- Update nginx configs for SSL on GPS port 5055
- Add timezone setting support
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 18:13:17 +01:00
parent 3814d175ff
commit 5ded039793
91 changed files with 4403 additions and 68 deletions

View File

@@ -23,6 +23,7 @@ import { UserList } from '@/pages/UserList';
import { AdminTools } from '@/pages/AdminTools';
import { DriverProfile } from '@/pages/DriverProfile';
import { MySchedule } from '@/pages/MySchedule';
import { GpsTracking } from '@/pages/GpsTracking';
import { useAuth } from '@/contexts/AuthContext';
// Smart redirect based on user role
@@ -120,6 +121,7 @@ function App() {
<Route path="/flights" element={<FlightList />} />
<Route path="/users" element={<UserList />} />
<Route path="/admin-tools" element={<AdminTools />} />
<Route path="/gps-tracking" element={<GpsTracking />} />
<Route path="/profile" element={<DriverProfile />} />
<Route path="/my-schedule" element={<MySchedule />} />
<Route path="/" element={<HomeRedirect />} />

View File

@@ -80,6 +80,7 @@ export function Layout({ children }: LayoutProps) {
// Admin dropdown items (nested under Admin)
const adminItems = [
{ name: 'Users', href: '/users', icon: UserCog },
{ name: 'GPS Tracking', href: '/gps-tracking', icon: Radio },
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings },
];
@@ -89,8 +90,6 @@ export function Layout({ children }: LayoutProps) {
if (item.driverOnly) return isDriverRole;
// Coordinator-only items hidden from drivers
if (item.coordinatorOnly && isDriverRole) return false;
// Always show items
if (item.alwaysShow) return true;
// Permission-based items
if (item.requireRead) {
return ability.can(Action.Read, item.requireRead);

View File

@@ -3,25 +3,34 @@ import { Loader2 } from 'lucide-react';
interface LoadingProps {
message?: string;
fullPage?: boolean;
size?: 'small' | 'medium' | 'large';
}
export function Loading({ message = 'Loading...', fullPage = false }: LoadingProps) {
export function Loading({ message = 'Loading...', fullPage = false, size = 'medium' }: LoadingProps) {
const sizeClasses = {
small: { icon: 'h-5 w-5', text: 'text-sm', padding: 'py-4' },
medium: { icon: 'h-8 w-8', text: 'text-base', padding: 'py-12' },
large: { icon: 'h-12 w-12', text: 'text-lg', padding: 'py-16' },
};
const { icon, text, padding } = sizeClasses[size];
if (fullPage) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted">
<div className="text-center">
<Loader2 className="h-12 w-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-muted-foreground text-lg">{message}</p>
<Loader2 className={`${icon} text-primary animate-spin mx-auto mb-4`} />
<p className={`text-muted-foreground ${text}`}>{message}</p>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center py-12">
<div className={`flex items-center justify-center ${padding}`}>
<div className="text-center">
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-3" />
<p className="text-muted-foreground">{message}</p>
<Loader2 className={`${icon} text-primary animate-spin mx-auto mb-3`} />
<p className={`text-muted-foreground ${text}`}>{message}</p>
</div>
</div>
);

View File

@@ -13,6 +13,7 @@ import {
Eye,
ChevronDown,
ChevronUp,
Globe,
} from 'lucide-react';
import {
usePdfSettings,
@@ -38,7 +39,7 @@ export function PdfSettingsSection() {
});
const fileInputRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch, reset } = useForm<UpdatePdfSettingsDto>();
const { register, handleSubmit, watch, reset, setValue } = useForm<UpdatePdfSettingsDto>();
const accentColor = watch('accentColor');
@@ -60,6 +61,7 @@ export function PdfSettingsSection() {
showTimestamp: settings.showTimestamp,
showAppUrl: settings.showAppUrl,
pageSize: settings.pageSize,
timezone: settings.timezone,
showFlightInfo: settings.showFlightInfo,
showDriverNames: settings.showDriverNames,
showVehicleNames: settings.showVehicleNames,
@@ -350,7 +352,8 @@ export function PdfSettingsSection() {
<div className="flex items-center gap-3">
<input
type="color"
{...register('accentColor')}
value={accentColor || '#2c3e50'}
onChange={(e) => setValue('accentColor', e.target.value)}
className="h-10 w-20 border border-input rounded cursor-pointer"
/>
<input
@@ -554,6 +557,39 @@ export function PdfSettingsSection() {
<option value={PageSize.A4}>A4 (210mm x 297mm)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
<Globe className="h-4 w-4 inline mr-1" />
System Timezone
</label>
<select
{...register('timezone')}
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
>
<optgroup label="US Timezones">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Phoenix">Arizona (no DST)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
</optgroup>
<optgroup label="International">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="Europe/London">London (GMT/BST)</option>
<option value="Europe/Paris">Paris (CET/CEST)</option>
<option value="Europe/Berlin">Berlin (CET/CEST)</option>
<option value="Asia/Tokyo">Tokyo (JST)</option>
<option value="Asia/Shanghai">Shanghai (CST)</option>
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
</optgroup>
</select>
<p className="text-xs text-muted-foreground mt-1">
All times in correspondence and exports will use this timezone
</p>
</div>
</div>
)}
</div>

View File

@@ -1,7 +1,8 @@
import { createContext, useContext, ReactNode } from 'react';
import { createContextualCan } from '@casl/react';
import { createContext, useContext, ReactNode, Consumer } from 'react';
import { createContextualCan, BoundCanProps } from '@casl/react';
import { defineAbilitiesFor, AppAbility, User } from '@/lib/abilities';
import { useAuth } from './AuthContext';
import { AnyAbility } from '@casl/ability';
/**
* CASL Ability Context
@@ -21,7 +22,7 @@ const AbilityContext = createContext<AppAbility | undefined>(undefined);
* <button>Edit Event</button>
* </Can>
*/
export const Can = createContextualCan(AbilityContext.Consumer);
export const Can = createContextualCan(AbilityContext.Consumer as Consumer<AnyAbility>);
/**
* Provider component that wraps the app with CASL abilities

View File

@@ -0,0 +1,333 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import type {
DriverLocation,
GpsDevice,
DriverStats,
GpsStatus,
GpsSettings,
EnrollmentResponse,
MyGpsStatus,
} from '@/types/gps';
import toast from 'react-hot-toast';
// ============================================
// Admin GPS Hooks
// ============================================
/**
* Get GPS system status
*/
export function useGpsStatus() {
return useQuery<GpsStatus>({
queryKey: ['gps', 'status'],
queryFn: async () => {
const { data } = await api.get('/gps/status');
return data;
},
refetchInterval: 30000, // Refresh every 30 seconds
});
}
/**
* Get GPS settings
*/
export function useGpsSettings() {
return useQuery<GpsSettings>({
queryKey: ['gps', 'settings'],
queryFn: async () => {
const { data } = await api.get('/gps/settings');
return data;
},
});
}
/**
* Update GPS settings
*/
export function useUpdateGpsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (settings: Partial<GpsSettings>) => {
const { data } = await api.patch('/gps/settings', settings);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'settings'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
toast.success('GPS settings updated');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update GPS settings');
},
});
}
/**
* Get all enrolled GPS devices
*/
export function useGpsDevices() {
return useQuery<GpsDevice[]>({
queryKey: ['gps', 'devices'],
queryFn: async () => {
const { data } = await api.get('/gps/devices');
return data;
},
});
}
/**
* Get all active driver locations (for map)
*/
export function useDriverLocations() {
return useQuery<DriverLocation[]>({
queryKey: ['gps', 'locations'],
queryFn: async () => {
const { data } = await api.get('/gps/locations');
return data;
},
refetchInterval: 30000, // Refresh every 30 seconds
});
}
/**
* Get a specific driver's location
*/
export function useDriverLocation(driverId: string) {
return useQuery<DriverLocation>({
queryKey: ['gps', 'locations', driverId],
queryFn: async () => {
const { data } = await api.get(`/gps/locations/${driverId}`);
return data;
},
enabled: !!driverId,
refetchInterval: 30000,
});
}
/**
* Get driver stats
*/
export function useDriverStats(driverId: string, from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: ['gps', 'stats', driverId, from, to],
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
const { data } = await api.get(`/gps/stats/${driverId}?${params.toString()}`);
return data;
},
enabled: !!driverId,
});
}
/**
* Enroll a driver for GPS tracking
*/
export function useEnrollDriver() {
const queryClient = useQueryClient();
return useMutation<EnrollmentResponse, Error, { driverId: string; sendSignalMessage?: boolean }>({
mutationFn: async ({ driverId, sendSignalMessage = true }) => {
const { data } = await api.post(`/gps/enroll/${driverId}`, { sendSignalMessage });
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
if (data.signalMessageSent) {
toast.success('Driver enrolled! Setup instructions sent via Signal.');
} else {
toast.success('Driver enrolled! Share the setup instructions with them.');
}
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to enroll driver');
},
});
}
/**
* Unenroll a driver from GPS tracking
*/
export function useUnenrollDriver() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (driverId: string) => {
const { data } = await api.delete(`/gps/devices/${driverId}`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'devices'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'locations'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
toast.success('Driver unenrolled from GPS tracking');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to unenroll driver');
},
});
}
// ============================================
// Driver Self-Service Hooks
// ============================================
/**
* Get my GPS enrollment status (for drivers)
*/
export function useMyGpsStatus() {
return useQuery<MyGpsStatus>({
queryKey: ['gps', 'me'],
queryFn: async () => {
const { data } = await api.get('/gps/me');
return data;
},
});
}
/**
* Get my GPS stats (for drivers)
*/
export function useMyGpsStats(from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: ['gps', 'me', 'stats', from, to],
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
const { data } = await api.get(`/gps/me/stats?${params.toString()}`);
return data;
},
});
}
/**
* Get my current location (for drivers)
*/
export function useMyLocation() {
return useQuery<DriverLocation>({
queryKey: ['gps', 'me', 'location'],
queryFn: async () => {
const { data } = await api.get('/gps/me/location');
return data;
},
refetchInterval: 30000,
});
}
/**
* Confirm/revoke GPS tracking consent (for drivers)
*/
export function useUpdateGpsConsent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (consentGiven: boolean) => {
const { data } = await api.post('/gps/me/consent', { consentGiven });
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['gps', 'me'] });
toast.success(data.message);
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update consent');
},
});
}
// ============================================
// Traccar Admin Hooks
// ============================================
/**
* Check Traccar setup status
*/
export function useTraccarSetupStatus() {
return useQuery<{ needsSetup: boolean; isAvailable: boolean }>({
queryKey: ['gps', 'traccar', 'status'],
queryFn: async () => {
const { data } = await api.get('/gps/traccar/status');
return data;
},
});
}
/**
* Perform initial Traccar setup
*/
export function useTraccarSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await api.post('/gps/traccar/setup');
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gps', 'traccar', 'status'] });
queryClient.invalidateQueries({ queryKey: ['gps', 'status'] });
toast.success('Traccar setup complete!');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to setup Traccar');
},
});
}
/**
* Sync VIP admins to Traccar
*/
export function useSyncAdminsToTraccar() {
return useMutation({
mutationFn: async () => {
const { data } = await api.post('/gps/traccar/sync-admins');
return data;
},
onSuccess: (data: { synced: number; failed: number }) => {
toast.success(`Synced ${data.synced} admins to Traccar`);
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to sync admins');
},
});
}
/**
* Get Traccar admin URL for current user
*/
export function useTraccarAdminUrl() {
return useQuery<{ url: string; directAccess: boolean }>({
queryKey: ['gps', 'traccar', 'admin-url'],
queryFn: async () => {
const { data } = await api.get('/gps/traccar/admin-url');
return data;
},
enabled: false, // Only fetch when explicitly called
});
}
/**
* Open Traccar admin (fetches URL and opens in new tab)
*/
export function useOpenTraccarAdmin() {
return useMutation({
mutationFn: async () => {
const { data } = await api.get('/gps/traccar/admin-url');
return data;
},
onSuccess: (data: { url: string; directAccess: boolean }) => {
// Open Traccar in new tab
window.open(data.url, '_blank');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to open Traccar admin');
},
});
}

View File

@@ -23,6 +23,8 @@ import {
FileText,
Upload,
Palette,
ExternalLink,
Shield,
} from 'lucide-react';
interface Stats {
@@ -64,6 +66,11 @@ export function AdminTools() {
const [testMessage, setTestMessage] = useState('');
const [testRecipient, setTestRecipient] = useState('');
// CAPTCHA state
const [showCaptcha, setShowCaptcha] = useState(false);
const [captchaToken, setCaptchaToken] = useState('');
const [captchaUrl, setCaptchaUrl] = useState('');
// Signal status query
const { data: signalStatus, isLoading: signalLoading, refetch: refetchSignal } = useQuery<SignalStatus>({
queryKey: ['signal-status'],
@@ -200,7 +207,7 @@ export function AdminTools() {
}
};
const handleRegisterNumber = async () => {
const handleRegisterNumber = async (captcha?: string) => {
if (!registerPhone) {
toast.error('Please enter a phone number');
return;
@@ -208,11 +215,22 @@ export function AdminTools() {
setIsLoading(true);
try {
const { data } = await api.post('/signal/register', { phoneNumber: registerPhone });
const { data } = await api.post('/signal/register', {
phoneNumber: registerPhone,
captcha: captcha,
});
if (data.success) {
toast.success(data.message);
setShowRegister(false);
setShowCaptcha(false);
setCaptchaToken('');
setShowVerify(true);
} else if (data.captchaRequired) {
// CAPTCHA is required - show the CAPTCHA modal
setCaptchaUrl(data.captchaUrl || 'https://signalcaptchas.org/registration/generate.html');
setShowCaptcha(true);
toast.error('CAPTCHA verification required');
} else {
toast.error(data.message);
}
@@ -224,6 +242,22 @@ export function AdminTools() {
}
};
const handleSubmitCaptcha = async () => {
if (!captchaToken) {
toast.error('Please paste the CAPTCHA token');
return;
}
// Clean up the token - extract just the token part if they pasted the full URL
let token = captchaToken.trim();
if (token.startsWith('signalcaptcha://')) {
token = token.replace('signalcaptcha://', '');
}
// Retry registration with the captcha token
await handleRegisterNumber(token);
};
const handleVerifyNumber = async () => {
if (!verifyCode) {
toast.error('Please enter the verification code');
@@ -529,7 +563,7 @@ export function AdminTools() {
)}
{/* Register Phone Number */}
{showRegister && (
{showRegister && !showCaptcha && (
<div className="mb-6 p-4 border border-border rounded-lg">
<h3 className="font-medium text-foreground mb-3">Register Phone Number</h3>
<div className="flex gap-3">
@@ -541,7 +575,7 @@ export function AdminTools() {
className="flex-1 px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
onClick={handleRegisterNumber}
onClick={() => handleRegisterNumber()}
disabled={isLoading || !registerPhone}
className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50"
>
@@ -560,6 +594,71 @@ export function AdminTools() {
</div>
)}
{/* CAPTCHA Challenge Modal */}
{showCaptcha && (
<div className="mb-6 p-4 border-2 border-yellow-400 dark:border-yellow-600 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
<div className="flex items-center gap-2 mb-3">
<Shield className="h-5 w-5 text-yellow-600" />
<h3 className="font-medium text-yellow-900 dark:text-yellow-200">CAPTCHA Verification Required</h3>
</div>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4">
Signal requires CAPTCHA verification to register this number. Follow these steps:
</p>
<ol className="text-sm text-yellow-800 dark:text-yellow-300 mb-4 list-decimal list-inside space-y-2">
<li>
<a
href={captchaUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
>
Open the CAPTCHA page <ExternalLink className="h-3 w-3" />
</a>
</li>
<li>Solve the CAPTCHA puzzle</li>
<li>When the "Open Signal" button appears, <strong>right-click</strong> it</li>
<li>Select "Copy link address" or "Copy Link"</li>
<li>Paste the full link below (starts with <code className="bg-yellow-200 dark:bg-yellow-800 px-1 rounded">signalcaptcha://</code>)</li>
</ol>
<div className="space-y-3">
<input
type="text"
value={captchaToken}
onChange={(e) => setCaptchaToken(e.target.value)}
placeholder="signalcaptcha://signal-hcaptcha..."
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary font-mono text-sm"
/>
<div className="flex gap-3">
<button
onClick={handleSubmitCaptcha}
disabled={isLoading || !captchaToken}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 mr-2 inline animate-spin" />
Verifying...
</>
) : (
'Submit CAPTCHA & Continue'
)}
</button>
<button
onClick={() => {
setShowCaptcha(false);
setCaptchaToken('');
setShowRegister(false);
setRegisterPhone('');
}}
className="px-4 py-2 border border-input text-foreground rounded-md hover:bg-accent"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Verify Code */}
{showVerify && (
<div className="mb-6 p-4 border border-border rounded-lg">

View File

@@ -153,6 +153,18 @@ export function CommandCenter() {
},
});
// Compute awaiting confirmation BEFORE any conditional returns (for hooks)
const now = currentTime;
const awaitingConfirmation = (events || []).filter((event) => {
if (event.status !== 'SCHEDULED' || event.type !== 'TRANSPORT') return false;
const start = new Date(event.startTime);
return start <= now;
});
// Check which awaiting events have driver responses since the event started
// MUST be called before any conditional returns to satisfy React's rules of hooks
const { data: respondedEventIds } = useDriverResponseCheck(awaitingConfirmation);
// Update clock every second
useEffect(() => {
const clockInterval = setInterval(() => {
@@ -242,7 +254,6 @@ export function CommandCenter() {
return <Loading message="Loading Command Center..." />;
}
const now = currentTime;
const fifteenMinutes = new Date(now.getTime() + 15 * 60 * 1000);
const thirtyMinutes = new Date(now.getTime() + 30 * 60 * 1000);
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
@@ -253,17 +264,6 @@ export function CommandCenter() {
(event) => event.status === 'IN_PROGRESS' && event.type === 'TRANSPORT'
);
// Trips that SHOULD be active (past start time but still SCHEDULED)
// These are awaiting driver confirmation
const awaitingConfirmation = events.filter((event) => {
if (event.status !== 'SCHEDULED' || event.type !== 'TRANSPORT') return false;
const start = new Date(event.startTime);
return start <= now;
});
// Check which awaiting events have driver responses since the event started
const { data: respondedEventIds } = useDriverResponseCheck(awaitingConfirmation);
// Upcoming trips in next 2 hours
const upcomingTrips = events
.filter((event) => {

View File

@@ -2,8 +2,23 @@ import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Loading } from '@/components/Loading';
import { User, Phone, Save, CheckCircle, AlertCircle } from 'lucide-react';
import {
User,
Phone,
Save,
CheckCircle,
AlertCircle,
MapPin,
Navigation,
Route,
Gauge,
Car,
Clock,
Shield,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useMyGpsStatus, useMyGpsStats, useUpdateGpsConsent } from '@/hooks/useGps';
import { formatDistanceToNow } from 'date-fns';
interface DriverProfileData {
id: string;
@@ -221,6 +236,182 @@ export function DriverProfile() {
<li>Trip start confirmation request</li>
</ul>
</div>
{/* GPS Tracking Section */}
<GpsStatsSection />
</div>
);
}
function GpsStatsSection() {
const { data: gpsStatus, isLoading: statusLoading } = useMyGpsStatus();
const { data: gpsStats, isLoading: statsLoading } = useMyGpsStats();
const updateConsent = useUpdateGpsConsent();
if (statusLoading) {
return (
<div className="bg-card border border-border rounded-lg shadow-soft p-6">
<div className="flex items-center gap-2 mb-4">
<MapPin className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">GPS Tracking</h2>
</div>
<Loading size="small" />
</div>
);
}
// Not enrolled
if (!gpsStatus?.enrolled) {
return (
<div className="bg-card border border-border rounded-lg shadow-soft p-6">
<div className="flex items-center gap-2 mb-4">
<MapPin className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">GPS Tracking</h2>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<MapPin className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
GPS tracking has not been set up for your account.
</p>
<p className="text-sm text-muted-foreground mt-1">
Contact an administrator if you need GPS tracking enabled.
</p>
</div>
</div>
);
}
// Enrolled but consent not given
if (!gpsStatus.consentGiven) {
return (
<div className="bg-card border border-border rounded-lg shadow-soft p-6">
<div className="flex items-center gap-2 mb-4">
<MapPin className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">GPS Tracking</h2>
</div>
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-amber-800 dark:text-amber-200">
Consent Required
</h3>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
GPS tracking is set up for your account, but you need to provide consent before location tracking begins.
</p>
<ul className="text-sm text-amber-700 dark:text-amber-300 mt-2 space-y-1 list-disc list-inside">
<li>Location is only tracked during shift hours (4 AM - 1 AM)</li>
<li>You can view your own driving stats (miles, speed, etc.)</li>
<li>Data is automatically deleted after 30 days</li>
<li>You can revoke consent at any time</li>
</ul>
</div>
</div>
</div>
<button
onClick={() => updateConsent.mutate(true)}
disabled={updateConsent.isPending}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50"
>
{updateConsent.isPending ? 'Processing...' : 'Accept GPS Tracking'}
</button>
</div>
);
}
// Enrolled and consent given - show stats
return (
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
<div className="p-6 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">GPS Tracking</h2>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
gpsStatus.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
}`}>
{gpsStatus.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
{gpsStatus.lastActive && (
<p className="text-sm text-muted-foreground mt-1">
Last seen: {formatDistanceToNow(new Date(gpsStatus.lastActive), { addSuffix: true })}
</p>
)}
</div>
{/* Stats Grid */}
{statsLoading ? (
<div className="p-6">
<Loading size="small" />
</div>
) : gpsStats ? (
<div className="p-6">
<p className="text-sm text-muted-foreground mb-4">
Stats for the last 7 days ({new Date(gpsStats.period.from).toLocaleDateString()} - {new Date(gpsStats.period.to).toLocaleDateString()})
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-muted/50 rounded-lg p-4 text-center">
<Route className="h-8 w-8 text-blue-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-foreground">{gpsStats.stats.totalMiles}</p>
<p className="text-xs text-muted-foreground">Miles Driven</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<Gauge className="h-8 w-8 text-red-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-foreground">{gpsStats.stats.topSpeedMph}</p>
<p className="text-xs text-muted-foreground">Top Speed (mph)</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<Navigation className="h-8 w-8 text-green-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-foreground">{gpsStats.stats.averageSpeedMph}</p>
<p className="text-xs text-muted-foreground">Avg Speed (mph)</p>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<Car className="h-8 w-8 text-purple-500 mx-auto mb-2" />
<p className="text-2xl font-bold text-foreground">{gpsStats.stats.totalTrips}</p>
<p className="text-xs text-muted-foreground">Total Trips</p>
</div>
</div>
{gpsStats.stats.topSpeedTimestamp && (
<p className="text-xs text-muted-foreground mt-4 text-center">
Top speed recorded on {new Date(gpsStats.stats.topSpeedTimestamp).toLocaleString()}
</p>
)}
</div>
) : (
<div className="p-6 text-center text-muted-foreground">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No driving data available yet</p>
<p className="text-sm">Start driving to see your stats!</p>
</div>
)}
{/* Revoke Consent Option */}
<div className="p-4 border-t border-border bg-muted/30">
<button
onClick={() => {
if (confirm('Are you sure you want to revoke GPS tracking consent? Your location will no longer be tracked.')) {
updateConsent.mutate(false);
}
}}
disabled={updateConsent.isPending}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Revoke tracking consent
</button>
</div>
</div>
);
}

View File

@@ -208,15 +208,15 @@ export function EventList() {
return sorted;
}, [events, activeFilter, searchQuery, sortField, sortDirection]);
const filterTabs: { label: string; value: ActivityFilter; count: number }[] = useMemo(() => {
if (!events) return [];
const filterTabs = useMemo(() => {
if (!events) return [] as { label: string; value: ActivityFilter; count: number }[];
return [
{ label: 'All', value: 'ALL', count: events.length },
{ label: 'Transport', value: 'TRANSPORT', count: events.filter(e => e.type === 'TRANSPORT').length },
{ label: 'Meals', value: 'MEAL', count: events.filter(e => e.type === 'MEAL').length },
{ label: 'Events', value: 'EVENT', count: events.filter(e => e.type === 'EVENT').length },
{ label: 'Meetings', value: 'MEETING', count: events.filter(e => e.type === 'MEETING').length },
{ label: 'Accommodation', value: 'ACCOMMODATION', count: events.filter(e => e.type === 'ACCOMMODATION').length },
{ label: 'All', value: 'ALL' as ActivityFilter, count: events.length },
{ label: 'Transport', value: 'TRANSPORT' as ActivityFilter, count: events.filter(e => e.type === 'TRANSPORT').length },
{ label: 'Meals', value: 'MEAL' as ActivityFilter, count: events.filter(e => e.type === 'MEAL').length },
{ label: 'Events', value: 'EVENT' as ActivityFilter, count: events.filter(e => e.type === 'EVENT').length },
{ label: 'Meetings', value: 'MEETING' as ActivityFilter, count: events.filter(e => e.type === 'MEETING').length },
{ label: 'Accommodation', value: 'ACCOMMODATION' as ActivityFilter, count: events.filter(e => e.type === 'ACCOMMODATION').length },
];
}, [events]);

100
frontend/src/types/gps.ts Normal file
View File

@@ -0,0 +1,100 @@
export interface LocationData {
latitude: number;
longitude: number;
altitude: number | null;
speed: number | null; // mph
course: number | null;
accuracy: number | null;
battery: number | null;
timestamp: string;
}
export interface DriverLocation {
driverId: string;
driverName: string;
driverPhone: string | null;
deviceIdentifier: string;
isActive: boolean;
lastActive: string | null;
location: LocationData | null;
}
export interface GpsDevice {
id: string;
driverId: string;
traccarDeviceId: number;
deviceIdentifier: string;
enrolledAt: string;
consentGiven: boolean;
consentGivenAt: string | null;
lastActive: string | null;
isActive: boolean;
driver: {
id: string;
name: string;
phone: string | null;
};
}
export interface DriverStats {
driverId: string;
driverName: string;
period: {
from: string;
to: string;
};
stats: {
totalMiles: number;
topSpeedMph: number;
topSpeedTimestamp: string | null;
averageSpeedMph: number;
totalTrips: number;
totalDrivingMinutes: number;
};
recentLocations: LocationData[];
}
export interface GpsStatus {
traccarAvailable: boolean;
traccarVersion: string | null;
enrolledDrivers: number;
activeDrivers: number;
settings: {
updateIntervalSeconds: number;
shiftStartTime: string;
shiftEndTime: string;
retentionDays: number;
};
}
export interface GpsSettings {
id: string;
updateIntervalSeconds: number;
shiftStartHour: number;
shiftStartMinute: number;
shiftEndHour: number;
shiftEndMinute: number;
retentionDays: number;
traccarAdminUser: string;
traccarAdminPassword: string | null;
}
export interface EnrollmentResponse {
success: boolean;
deviceIdentifier: string;
serverUrl: string;
port: number;
instructions: string;
signalMessageSent?: boolean;
}
export interface MyGpsStatus {
enrolled: boolean;
driverId?: string;
deviceIdentifier?: string;
consentGiven?: boolean;
consentGivenAt?: string;
isActive?: boolean;
lastActive?: string;
message?: string;
}

View File

@@ -25,6 +25,7 @@ export interface PdfSettings {
showTimestamp: boolean;
showAppUrl: boolean;
pageSize: PageSize;
timezone: string;
// Content Toggles
showFlightInfo: boolean;
@@ -60,6 +61,7 @@ export interface UpdatePdfSettingsDto {
showTimestamp?: boolean;
showAppUrl?: boolean;
pageSize?: PageSize;
timezone?: string;
// Content Toggles
showFlightInfo?: boolean;