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>
@@ -8,9 +8,11 @@ yarn-error.log*
|
||||
dist
|
||||
build
|
||||
|
||||
# Environment files (injected at build time via args)
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
.env.development
|
||||
!.env.production
|
||||
!.env.example
|
||||
|
||||
# Testing
|
||||
|
||||
@@ -12,23 +12,11 @@ COPY package*.json ./
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy application source
|
||||
# Copy application source (includes .env.production with correct values)
|
||||
COPY . .
|
||||
|
||||
# Accept build-time environment variables
|
||||
# These are embedded into the build by Vite
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_AUTH0_DOMAIN
|
||||
ARG VITE_AUTH0_CLIENT_ID
|
||||
ARG VITE_AUTH0_AUDIENCE
|
||||
|
||||
# Set environment variables for build
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_AUTH0_DOMAIN=$VITE_AUTH0_DOMAIN
|
||||
ENV VITE_AUTH0_CLIENT_ID=$VITE_AUTH0_CLIENT_ID
|
||||
ENV VITE_AUTH0_AUDIENCE=$VITE_AUTH0_AUDIENCE
|
||||
|
||||
# Build the application (skip tsc check, vite build only)
|
||||
# Build the application
|
||||
# Vite automatically uses .env.production for production builds
|
||||
RUN npx vite build
|
||||
|
||||
# ==========================================
|
||||
|
||||
326
frontend/EXAMPLE_PDF_USAGE.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* EXAMPLE: How to Use VIP Schedule PDF Generation
|
||||
*
|
||||
* This file demonstrates the complete flow of generating a VIP schedule PDF.
|
||||
* This is a reference example - the actual implementation is in:
|
||||
* - src/components/VIPSchedulePDF.tsx (PDF component)
|
||||
* - src/pages/VIPSchedule.tsx (integration)
|
||||
*/
|
||||
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||
|
||||
// Example VIP data
|
||||
const exampleVIP = {
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
organization: 'Example Corporation',
|
||||
department: 'OFFICE_OF_DEVELOPMENT',
|
||||
arrivalMode: 'FLIGHT',
|
||||
expectedArrival: '2026-02-03T09:00:00Z',
|
||||
airportPickup: true,
|
||||
venueTransport: true,
|
||||
notes: 'VIP prefers electric vehicles. Dietary restriction: vegetarian.',
|
||||
flights: [
|
||||
{
|
||||
id: 'f1',
|
||||
flightNumber: 'AA123',
|
||||
departureAirport: 'JFK',
|
||||
arrivalAirport: 'LAX',
|
||||
scheduledDeparture: '2026-02-03T07:00:00Z',
|
||||
scheduledArrival: '2026-02-03T10:00:00Z',
|
||||
status: 'On Time',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Example schedule events
|
||||
const exampleEvents = [
|
||||
{
|
||||
id: 'e1',
|
||||
title: 'Airport Pickup',
|
||||
type: 'TRANSPORT',
|
||||
status: 'SCHEDULED',
|
||||
startTime: '2026-02-03T10:00:00Z',
|
||||
endTime: '2026-02-03T11:00:00Z',
|
||||
pickupLocation: 'LAX Terminal 4',
|
||||
dropoffLocation: 'Hotel Grand Plaza',
|
||||
description: 'Pick up from arrival gate',
|
||||
driver: {
|
||||
id: 'd1',
|
||||
name: 'Mike Johnson',
|
||||
},
|
||||
vehicle: {
|
||||
id: 'v1',
|
||||
name: 'Tesla Model S',
|
||||
type: 'SEDAN',
|
||||
seatCapacity: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
title: 'Welcome Lunch',
|
||||
type: 'MEAL',
|
||||
status: 'SCHEDULED',
|
||||
startTime: '2026-02-03T12:00:00Z',
|
||||
endTime: '2026-02-03T13:30:00Z',
|
||||
location: 'Restaurant Chez Pierre',
|
||||
description: 'Lunch with board members',
|
||||
driver: null,
|
||||
vehicle: null,
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
title: 'Board Meeting',
|
||||
type: 'MEETING',
|
||||
status: 'SCHEDULED',
|
||||
startTime: '2026-02-03T14:00:00Z',
|
||||
endTime: '2026-02-03T17:00:00Z',
|
||||
location: 'Conference Room A, 5th Floor',
|
||||
description: 'Q1 strategy discussion',
|
||||
driver: null,
|
||||
vehicle: null,
|
||||
},
|
||||
{
|
||||
id: 'e4',
|
||||
title: 'Airport Return',
|
||||
type: 'TRANSPORT',
|
||||
status: 'SCHEDULED',
|
||||
startTime: '2026-02-04T15:00:00Z',
|
||||
endTime: '2026-02-04T16:00:00Z',
|
||||
pickupLocation: 'Hotel Grand Plaza',
|
||||
dropoffLocation: 'LAX Terminal 4',
|
||||
description: 'Departure for flight home',
|
||||
driver: {
|
||||
id: 'd1',
|
||||
name: 'Mike Johnson',
|
||||
},
|
||||
vehicle: {
|
||||
id: 'v1',
|
||||
name: 'Tesla Model S',
|
||||
type: 'SEDAN',
|
||||
seatCapacity: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* EXAMPLE 1: Basic PDF Generation
|
||||
*/
|
||||
export async function generateBasicPDF() {
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF vip={exampleVIP} events={exampleEvents} />
|
||||
).toBlob();
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${exampleVIP.name.replace(/\s+/g, '_')}_Schedule.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXAMPLE 2: PDF Generation with Custom Contact Info
|
||||
*/
|
||||
export async function generateCustomContactPDF() {
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF
|
||||
vip={exampleVIP}
|
||||
events={exampleEvents}
|
||||
contactEmail="custom-coordinator@example.com"
|
||||
contactPhone="(555) 987-6543"
|
||||
appUrl="https://my-vip-app.com"
|
||||
/>
|
||||
).toBlob();
|
||||
|
||||
// Download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'VIP_Schedule.pdf';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXAMPLE 3: PDF Generation with Environment Variables
|
||||
*/
|
||||
export async function generateEnvConfigPDF() {
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF
|
||||
vip={exampleVIP}
|
||||
events={exampleEvents}
|
||||
contactEmail={import.meta.env.VITE_CONTACT_EMAIL || 'coordinator@example.com'}
|
||||
contactPhone={import.meta.env.VITE_CONTACT_PHONE || '(555) 123-4567'}
|
||||
appUrl={window.location.origin}
|
||||
/>
|
||||
).toBlob();
|
||||
|
||||
// Download with timestamp
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${exampleVIP.name.replace(/\s+/g, '_')}_Schedule_${date}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXAMPLE 4: PDF Generation with Error Handling
|
||||
*/
|
||||
export async function generatePDFWithErrorHandling() {
|
||||
try {
|
||||
console.log('[PDF] Starting generation...');
|
||||
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF
|
||||
vip={exampleVIP}
|
||||
events={exampleEvents}
|
||||
contactEmail={import.meta.env.VITE_CONTACT_EMAIL}
|
||||
contactPhone={import.meta.env.VITE_CONTACT_PHONE}
|
||||
appUrl={window.location.origin}
|
||||
/>
|
||||
).toBlob();
|
||||
|
||||
console.log('[PDF] Generation successful, size:', blob.size, 'bytes');
|
||||
|
||||
// Create download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${exampleVIP.name.replace(/\s+/g, '_')}_Schedule.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[PDF] Download complete');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PDF] Generation failed:', error);
|
||||
alert('Failed to generate PDF. Please try again.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EXAMPLE 5: React Component Integration
|
||||
*/
|
||||
export function VIPScheduleExampleComponent() {
|
||||
const handleExportPDF = async () => {
|
||||
try {
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF
|
||||
vip={exampleVIP}
|
||||
events={exampleEvents}
|
||||
contactEmail={import.meta.env.VITE_CONTACT_EMAIL}
|
||||
contactPhone={import.meta.env.VITE_CONTACT_PHONE}
|
||||
appUrl={window.location.origin}
|
||||
/>
|
||||
).toBlob();
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${exampleVIP.name.replace(/\s+/g, '_')}_Schedule.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('[PDF] Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXAMPLE 6: Preview PDF in New Tab (instead of download)
|
||||
*/
|
||||
export async function previewPDFInNewTab() {
|
||||
const blob = await pdf(
|
||||
<VIPSchedulePDF vip={exampleVIP} events={exampleEvents} />
|
||||
).toBlob();
|
||||
|
||||
// Open in new tab instead of downloading
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
|
||||
// Clean up after a delay (user should have opened it by then)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected PDF Output Structure:
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────────┐
|
||||
* │ HEADER │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ John Doe │ │
|
||||
* │ │ Example Corporation │ │
|
||||
* │ │ OFFICE OF DEVELOPMENT • VIP Schedule & Itinerary │ │
|
||||
* │ │ │ │
|
||||
* │ │ ⚠️ DOCUMENT GENERATED AT: │ │
|
||||
* │ │ Saturday, February 1, 2026, 2:00 PM EST │ │
|
||||
* │ │ This is a snapshot. For latest: https://app.com │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ VIP INFORMATION │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ Arrival Mode: FLIGHT Expected: Feb 3, 9:00 AM │ │
|
||||
* │ │ Airport Pickup: Yes Venue Transport: Yes │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ FLIGHT INFORMATION │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ Flight AA123 │ │
|
||||
* │ │ JFK → LAX │ │
|
||||
* │ │ Arrives: Feb 3, 10:00 AM │ │
|
||||
* │ │ Status: On Time │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ SPECIAL NOTES │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ VIP prefers electric vehicles. Dietary: vegetarian │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ SCHEDULE & ITINERARY │
|
||||
* │ │
|
||||
* │ Monday, February 3, 2026 │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ 10:00 AM - 11:00 AM [TRANSPORT] │ │
|
||||
* │ │ Airport Pickup │ │
|
||||
* │ │ 📍 LAX Terminal 4 → Hotel Grand Plaza │ │
|
||||
* │ │ 👤 Driver: Mike Johnson │ │
|
||||
* │ │ 🚗 Tesla Model S (SEDAN) │ │
|
||||
* │ │ [SCHEDULED] │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ ┌──────────────────────────────────────────────────────┐ │
|
||||
* │ │ 12:00 PM - 1:30 PM [MEAL] │ │
|
||||
* │ │ Welcome Lunch │ │
|
||||
* │ │ 📍 Restaurant Chez Pierre │ │
|
||||
* │ │ [SCHEDULED] │ │
|
||||
* │ └──────────────────────────────────────────────────────┘ │
|
||||
* │ │
|
||||
* │ ... more events ... │
|
||||
* │ │
|
||||
* ├────────────────────────────────────────────────────────────┤
|
||||
* │ FOOTER │
|
||||
* │ For Questions: coordinator@vip-board.com │
|
||||
* │ Phone: (555) 123-4567 Page 1 of 2 │
|
||||
* └────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
BIN
frontend/after-login-click.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/auth0-login-page.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/before-login.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
@@ -29,8 +29,9 @@ server {
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# API proxy - forward all /api requests to backend service
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
# Strip /api prefix so /api/v1/health becomes /v1/health
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
81
frontend/package-lock.json
generated
@@ -17,11 +17,14 @@
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
@@ -29,6 +32,8 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
@@ -1082,6 +1087,17 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-pdf/fns": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz",
|
||||
@@ -1726,6 +1742,13 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -1742,6 +1765,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -1757,6 +1790,17 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
|
||||
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -4003,6 +4047,13 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -5341,6 +5392,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
@@ -5437,6 +5497,20 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -6164,6 +6238,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
|
||||
@@ -24,11 +24,14 @@
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.21.3",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
@@ -36,6 +39,8 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.0",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
|
||||
BIN
frontend/production-homepage.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
frontend/screenshots/01-login-light.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
frontend/screenshots/02-login-dark.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
frontend/screenshots/03-theme-blue.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
frontend/screenshots/03-theme-green.png
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
frontend/screenshots/03-theme-orange.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
frontend/screenshots/03-theme-purple.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
frontend/screenshots/04-dark-blue.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
frontend/screenshots/04-dark-green.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
frontend/screenshots/04-dark-orange.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
frontend/screenshots/04-dark-purple.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
frontend/screenshots/ai-copilot-button.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
frontend/screenshots/ai-copilot-panel-open.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
frontend/screenshots/auth-01-initial.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
frontend/screenshots/auth-02-after-login-click.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/screenshots/new-dark-mode-from-menu.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
frontend/screenshots/new-dashboard-dark.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/screenshots/new-header-clean.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
frontend/screenshots/new-user-menu-open.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
frontend/screenshots/review-01-login-page.png
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
frontend/screenshots/review-02-auth0-page.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/screenshots/review-03-credentials-filled.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/screenshots/review-04-after-login.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/screenshots/review-05-dashboard.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/screenshots/review-color-green.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/screenshots/review-color-orange.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/screenshots/review-color-purple.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/screenshots/review-dark-mode.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/screenshots/review-page-activities.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/screenshots/review-page-drivers.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/screenshots/review-page-flights.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/screenshots/review-page-vehicles.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/screenshots/review-page-vips.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
frontend/screenshots/review-page-war-room.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/screenshots/traccar-1-initial.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/screenshots/traccar-2-final.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/screenshots/traccar-test-1-vip.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/screenshots/traccar-test-2-traccar.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/screenshots/warroom-fixed.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
@@ -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 />} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
333
frontend/src/hooks/useGps.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
39
frontend/tests/production.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Production Site Tests', () => {
|
||||
test('should load production homepage', async ({ page }) => {
|
||||
console.log('Testing: https://vip.madeamess.online');
|
||||
|
||||
await page.goto('https://vip.madeamess.online', {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
// Check title
|
||||
await expect(page).toHaveTitle(/VIP Coordinator/i);
|
||||
console.log('✅ Page title correct');
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'production-screenshot.png', fullPage: true });
|
||||
console.log('✅ Screenshot saved');
|
||||
});
|
||||
|
||||
test('should have working API', async ({ request }) => {
|
||||
const response = await request.get('https://vip.madeamess.online/api/v1/health');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.status).toBe('ok');
|
||||
expect(data.environment).toBe('production');
|
||||
console.log('✅ API health check passed:', data);
|
||||
});
|
||||
|
||||
test('should load without errors', async ({ page }) => {
|
||||
await page.goto('https://vip.madeamess.online');
|
||||
|
||||
// Wait for React to render
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
console.log('✅ Page loaded successfully');
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,8 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||