5 Commits

Author SHA1 Message Date
858793d698 feat: consolidate Drivers and Vehicles into tabbed Fleet page
Replaces separate /drivers and /vehicles routes with a single /fleet
page using tabs. Old routes redirect for backward compatibility.
Navigation sidebar now shows one "Fleet" item instead of two.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:42:32 +01:00
16c0fb65a6 feat: add blue airplane favicon using Lucide Plane icon
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:30:04 +01:00
42bab25766 feat: allow admins and coordinators to also be drivers
Add a "Driver" checkbox column to the User Management page. Checking it
creates a linked Driver record so the user appears in the drivers list,
can be assigned events, and enrolled for GPS tracking — without changing
their primary role. The DRIVER role checkbox is auto-checked and disabled
since being a driver is inherent to that role. Promoting a user from
DRIVER to Admin/Coordinator preserves their driver record.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:19:08 +01:00
ec7c5a6802 fix: auto-refresh enrolled devices list every 30 seconds
The useGpsDevices query was missing refetchInterval, so the Last Active
timestamp on the Enrolled Devices page only updated on initial page load.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:04:48 +01:00
a0d0cbc8f6 feat: add QR code to enrollment screen for Traccar Client setup
Generate a QR code URL containing device ID, server URL, and update
interval that the Traccar Client app can scan to auto-configure.
The enrollment modal now shows the QR prominently with manual setup
collapsed as a fallback. Also pins Traccar to 6.11 and fixes Docker
health checks (IPv6/curl issues).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:54:59 +01:00
17 changed files with 315 additions and 123 deletions

View File

@@ -5,6 +5,7 @@ import {
BadRequestException, BadRequestException,
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service'; import { SignalService } from '../signal/signal.service';
@@ -27,6 +28,7 @@ export class GpsService implements OnModuleInit {
private prisma: PrismaService, private prisma: PrismaService,
private traccarClient: TraccarClientService, private traccarClient: TraccarClientService,
private signalService: SignalService, private signalService: SignalService,
private configService: ConfigService,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -141,6 +143,7 @@ export class GpsService implements OnModuleInit {
success: boolean; success: boolean;
deviceIdentifier: string; deviceIdentifier: string;
serverUrl: string; serverUrl: string;
qrCodeUrl: string;
instructions: string; instructions: string;
signalMessageSent?: boolean; signalMessageSent?: boolean;
}> { }> {
@@ -192,6 +195,19 @@ export class GpsService implements OnModuleInit {
const serverUrl = this.traccarClient.getDeviceServerUrl(); const serverUrl = this.traccarClient.getDeviceServerUrl();
const settings = await this.getSettings(); const settings = await this.getSettings();
// Build QR code URL for Traccar Client app
// Format: https://server:5055?id=DEVICE_ID&interval=SECONDS
// The Traccar Client app parses this as: server URL (origin) + query params (id, interval, etc.)
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', actualDeviceId);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
const qrCodeUrl = qrUrl.toString();
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
const instructions = ` const instructions = `
GPS Tracking Setup Instructions for ${driver.name}: GPS Tracking Setup Instructions for ${driver.name}:
@@ -234,6 +250,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
success: true, success: true,
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
serverUrl, serverUrl,
qrCodeUrl,
instructions, instructions,
signalMessageSent, signalMessageSent,
}; };

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsOptional } from 'class-validator'; import { IsString, IsEnum, IsOptional, IsBoolean } from 'class-validator';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
export class UpdateUserDto { export class UpdateUserDto {
@@ -9,4 +9,8 @@ export class UpdateUserDto {
@IsEnum(Role) @IsEnum(Role)
@IsOptional() @IsOptional()
role?: Role; role?: Role;
@IsBoolean()
@IsOptional()
isAlsoDriver?: boolean;
} }

View File

@@ -35,9 +35,10 @@ export class UsersService {
this.logger.log(`Updating user ${id}: ${JSON.stringify(updateUserDto)}`); this.logger.log(`Updating user ${id}: ${JSON.stringify(updateUserDto)}`);
// Handle role change and Driver record synchronization const { isAlsoDriver, ...prismaData } = updateUserDto;
if (updateUserDto.role && updateUserDto.role !== user.role) { const effectiveRole = updateUserDto.role || user.role;
// If changing TO DRIVER role, create a Driver record if one doesn't exist
// Handle role change to DRIVER: auto-create driver record
if (updateUserDto.role === Role.DRIVER && !user.driver) { if (updateUserDto.role === Role.DRIVER && !user.driver) {
this.logger.log( this.logger.log(
`Creating Driver record for user ${user.email} (role change to DRIVER)`, `Creating Driver record for user ${user.email} (role change to DRIVER)`,
@@ -45,26 +46,41 @@ export class UsersService {
await this.prisma.driver.create({ await this.prisma.driver.create({
data: { data: {
name: user.name || user.email, name: user.name || user.email,
phone: user.email, // Use email as placeholder for phone phone: user.email,
userId: user.id, userId: user.id,
}, },
}); });
} }
// If changing FROM DRIVER role to something else, remove the Driver record // When promoting FROM DRIVER to Admin/Coordinator, keep the driver record
if (user.role === Role.DRIVER && updateUserDto.role !== Role.DRIVER && user.driver) { // (admin can explicitly uncheck the driver box later if they want)
// Handle "Also a Driver" toggle (independent of role)
if (isAlsoDriver === true && !user.driver) {
this.logger.log( this.logger.log(
`Removing Driver record for user ${user.email} (role change from DRIVER to ${updateUserDto.role})`, `Creating Driver record for user ${user.email} (isAlsoDriver toggled on)`,
); );
await this.prisma.driver.delete({ await this.prisma.driver.create({
where: { id: user.driver.id }, data: {
name: user.name || user.email,
phone: user.email,
userId: user.id,
},
});
} else if (isAlsoDriver === false && user.driver && effectiveRole !== Role.DRIVER) {
// Only allow removing driver record if user is NOT in the DRIVER role
this.logger.log(
`Soft-deleting Driver record for user ${user.email} (isAlsoDriver toggled off)`,
);
await this.prisma.driver.update({
where: { id: user.driver.id },
data: { deletedAt: new Date() },
}); });
}
} }
return this.prisma.user.update({ return this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: updateUserDto, data: prismaData,
include: { driver: true }, include: { driver: true },
}); });
} }

View File

@@ -52,7 +52,7 @@ services:
# Traccar GPS Tracking Server # Traccar GPS Tracking Server
traccar: traccar:
image: traccar/traccar:latest image: traccar/traccar:6.11
container_name: vip-traccar container_name: vip-traccar
ports: ports:
- "8082:8082" # Web UI & API - "8082:8082" # Web UI & API

View File

@@ -46,7 +46,7 @@ EXPOSE 80
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VIP Coordinator</title> <title>VIP Coordinator</title>
<!-- Prevent FOUC (Flash of Unstyled Content) for theme --> <!-- Prevent FOUC (Flash of Unstyled Content) for theme -->

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#bg)"/>
<g transform="translate(4,4)" fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -15,8 +15,7 @@ import { Dashboard } from '@/pages/Dashboard';
import { CommandCenter } from '@/pages/CommandCenter'; import { CommandCenter } from '@/pages/CommandCenter';
import { VIPList } from '@/pages/VipList'; import { VIPList } from '@/pages/VipList';
import { VIPSchedule } from '@/pages/VIPSchedule'; import { VIPSchedule } from '@/pages/VIPSchedule';
import { DriverList } from '@/pages/DriverList'; import { FleetPage } from '@/pages/FleetPage';
import { VehicleList } from '@/pages/VehicleList';
import { EventList } from '@/pages/EventList'; import { EventList } from '@/pages/EventList';
import { FlightList } from '@/pages/FlightList'; import { FlightList } from '@/pages/FlightList';
import { UserList } from '@/pages/UserList'; import { UserList } from '@/pages/UserList';
@@ -115,8 +114,9 @@ function App() {
<Route path="/command-center" element={<CommandCenter />} /> <Route path="/command-center" element={<CommandCenter />} />
<Route path="/vips" element={<VIPList />} /> <Route path="/vips" element={<VIPList />} />
<Route path="/vips/:id/schedule" element={<VIPSchedule />} /> <Route path="/vips/:id/schedule" element={<VIPSchedule />} />
<Route path="/drivers" element={<DriverList />} /> <Route path="/fleet" element={<FleetPage />} />
<Route path="/vehicles" element={<VehicleList />} /> <Route path="/drivers" element={<Navigate to="/fleet?tab=drivers" replace />} />
<Route path="/vehicles" element={<Navigate to="/fleet?tab=vehicles" replace />} />
<Route path="/events" element={<EventList />} /> <Route path="/events" element={<EventList />} />
<Route path="/flights" element={<FlightList />} /> <Route path="/flights" element={<FlightList />} />
<Route path="/users" element={<UserList />} /> <Route path="/users" element={<UserList />} />

View File

@@ -9,7 +9,6 @@ import {
Plane, Plane,
Users, Users,
Car, Car,
Truck,
Calendar, Calendar,
UserCog, UserCog,
LayoutDashboard, LayoutDashboard,
@@ -71,8 +70,7 @@ export function Layout({ children }: LayoutProps) {
{ name: 'My Profile', href: '/profile', icon: UserCog, driverOnly: true }, { name: 'My Profile', href: '/profile', icon: UserCog, driverOnly: true },
{ name: 'War Room', href: '/command-center', icon: Radio, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true }, { name: 'War Room', href: '/command-center', icon: Radio, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true },
{ name: 'VIPs', href: '/vips', icon: Users, requireRead: 'VIP' as const, coordinatorOnly: true }, { name: 'VIPs', href: '/vips', icon: Users, requireRead: 'VIP' as const, coordinatorOnly: true },
{ name: 'Drivers', href: '/drivers', icon: Car, requireRead: 'Driver' as const, coordinatorOnly: true }, { name: 'Fleet', href: '/fleet', icon: Car, requireRead: 'Driver' as const, coordinatorOnly: true },
{ name: 'Vehicles', href: '/vehicles', icon: Truck, requireRead: 'Vehicle' as const, coordinatorOnly: true },
{ name: 'Activities', href: '/events', icon: Calendar, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true }, { name: 'Activities', href: '/events', icon: Calendar, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true },
{ name: 'Flights', href: '/flights', icon: Plane, requireRead: 'Flight' as const, coordinatorOnly: true }, { name: 'Flights', href: '/flights', icon: Plane, requireRead: 'Flight' as const, coordinatorOnly: true },
]; ];

View File

@@ -74,6 +74,7 @@ export function useGpsDevices() {
const { data } = await api.get('/gps/devices'); const { data } = await api.get('/gps/devices');
return data; return data;
}, },
refetchInterval: 30000, // Refresh every 30 seconds to update lastActive
}); });
} }

View File

@@ -378,7 +378,7 @@ export function CommandCenter() {
alerts.push({ alerts.push({
type: 'warning', type: 'warning',
message: 'No vehicles available - all vehicles in use', message: 'No vehicles available - all vehicles in use',
link: `/vehicles`, link: `/fleet?tab=vehicles`,
}); });
} }
@@ -902,7 +902,7 @@ export function CommandCenter() {
Events Events
</Link> </Link>
<Link <Link
to="/drivers" to="/fleet?tab=drivers"
className="flex items-center justify-center gap-1 px-2 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-medium hover:bg-purple-200 dark:hover:bg-purple-900/50" className="flex items-center justify-center gap-1 px-2 py-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-xs font-medium hover:bg-purple-200 dark:hover:bg-purple-900/50"
> >
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
@@ -916,7 +916,7 @@ export function CommandCenter() {
VIPs VIPs
</Link> </Link>
<Link <Link
to="/vehicles" to="/fleet?tab=vehicles"
className="flex items-center justify-center gap-1 px-2 py-2 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded text-xs font-medium hover:bg-orange-200 dark:hover:bg-orange-900/50" className="flex items-center justify-center gap-1 px-2 py-2 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded text-xs font-medium hover:bg-orange-200 dark:hover:bg-orange-900/50"
> >
<Car className="h-3 w-3" /> <Car className="h-3 w-3" />

View File

@@ -14,7 +14,7 @@ import { DriverChatModal } from '@/components/DriverChatModal';
import { DriverScheduleModal } from '@/components/DriverScheduleModal'; import { DriverScheduleModal } from '@/components/DriverScheduleModal';
import { useUnreadCounts } from '@/hooks/useSignalMessages'; import { useUnreadCounts } from '@/hooks/useSignalMessages';
export function DriverList() { export function DriverList({ embedded = false }: { embedded?: boolean }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingDriver, setEditingDriver] = useState<Driver | null>(null); const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
@@ -234,6 +234,7 @@ export function DriverList() {
if (isLoading) { if (isLoading) {
return ( return (
<div> <div>
{!embedded && (
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1> <h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1>
<button <button
@@ -245,6 +246,7 @@ export function DriverList() {
Add Driver Add Driver
</button> </button>
</div> </div>
)}
<div className="hidden lg:block"> <div className="hidden lg:block">
<TableSkeleton rows={8} /> <TableSkeleton rows={8} />
</div> </div>
@@ -257,6 +259,7 @@ export function DriverList() {
return ( return (
<div> <div>
{!embedded ? (
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1> <h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1>
<button <button
@@ -268,6 +271,18 @@ export function DriverList() {
Add Driver Add Driver
</button> </button>
</div> </div>
) : (
<div className="flex justify-end mb-4">
<button
onClick={handleAdd}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
style={{ minHeight: '44px' }}
>
<Plus className="h-5 w-5 mr-2" />
Add Driver
</button>
</div>
)}
{/* Search and Filter Section */} {/* Search and Filter Section */}
<div className="bg-card border border-border shadow-soft rounded-lg p-4 mb-6"> <div className="bg-card border border-border shadow-soft rounded-lg p-4 mb-6">

View File

@@ -0,0 +1,54 @@
import { useSearchParams } from 'react-router-dom';
import { Car, Truck } from 'lucide-react';
import { DriverList } from '@/pages/DriverList';
import { VehicleList } from '@/pages/VehicleList';
type FleetTab = 'drivers' | 'vehicles';
const TABS: { id: FleetTab; label: string; icon: typeof Car }[] = [
{ id: 'drivers', label: 'Drivers', icon: Car },
{ id: 'vehicles', label: 'Vehicles', icon: Truck },
];
export function FleetPage() {
const [searchParams, setSearchParams] = useSearchParams();
const rawTab = searchParams.get('tab');
const activeTab: FleetTab = rawTab === 'vehicles' ? 'vehicles' : 'drivers';
const handleTabChange = (tab: FleetTab) => {
setSearchParams({ tab }, { replace: true });
};
return (
<div>
<div className="mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Fleet Management</h1>
</div>
{/* Tab Bar */}
<div className="border-b border-border mb-6">
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`px-4 py-3 text-sm font-medium transition-colors flex items-center gap-2 border-b-2 ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
{activeTab === 'drivers' && <DriverList embedded />}
{activeTab === 'vehicles' && <VehicleList embedded />}
</div>
);
}

View File

@@ -768,18 +768,37 @@ export function GpsTracking() {
</div> </div>
)} )}
{/* Manual Configuration - Traccar Client doesn't reliably scan QR codes */} {/* QR Code - scan with Traccar Client app to auto-configure */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-4 rounded-lg border border-blue-200 dark:border-blue-800"> <div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center justify-center gap-2 mb-3">
<Smartphone className="h-5 w-5 text-blue-600" /> <QrCode className="h-5 w-5 text-primary" />
<span className="text-sm font-medium">Scan with Traccar Client</span>
</div>
<div className="flex justify-center mb-3">
<QRCodeSVG
value={enrollmentResult.qrCodeUrl}
size={200}
level="M"
includeMargin
/>
</div>
<p className="text-xs text-muted-foreground">
Open Traccar Client app tap the QR icon scan this code
</p>
</div>
{/* Download links */}
<div className="bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Smartphone className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span> <span className="text-sm font-medium text-blue-800 dark:text-blue-200">Download Traccar Client</span>
</div> </div>
<div className="flex gap-2 justify-center mb-3"> <div className="flex gap-2 justify-center">
<a <a
href="https://apps.apple.com/app/traccar-client/id843156974" href="https://apps.apple.com/app/traccar-client/id843156974"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700" className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
> >
iOS App Store iOS App Store
</a> </a>
@@ -787,17 +806,19 @@ export function GpsTracking() {
href="https://play.google.com/store/apps/details?id=org.traccar.client" href="https://play.google.com/store/apps/details?id=org.traccar.client"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700" className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
> >
Google Play Google Play
</a> </a>
</div> </div>
<p className="text-xs text-center text-blue-700 dark:text-blue-300">
Must use official Traccar Client app (not other GPS apps)
</p>
</div> </div>
<div className="space-y-3"> {/* Manual fallback - collapsible */}
<details className="bg-muted/30 rounded-lg border border-border">
<summary className="p-3 text-sm font-medium cursor-pointer hover:bg-muted/50 transition-colors">
Manual Setup (if QR doesn't work)
</summary>
<div className="px-3 pb-3 space-y-2">
<div> <div>
<label className="text-xs text-muted-foreground">Device ID</label> <label className="text-xs text-muted-foreground">Device ID</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -826,17 +847,13 @@ export function GpsTracking() {
</button> </button>
</div> </div>
</div> </div>
</div> <ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground mt-2">
<li>Open Traccar Client and enter Device ID and Server URL above</li>
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-sm font-medium mb-2">Driver Instructions:</p>
<ol className="text-sm space-y-1 list-decimal list-inside text-muted-foreground">
<li>Download "Traccar Client" from App Store or Play Store</li>
<li>Open app and enter the Device ID and Server URL</li>
<li>Set frequency to {settings?.updateIntervalSeconds || 60} seconds</li> <li>Set frequency to {settings?.updateIntervalSeconds || 60} seconds</li>
<li>Tap "Service Status" to start tracking</li> <li>Tap "Service Status" to start tracking</li>
</ol> </ol>
</div> </div>
</details>
<button <button
onClick={() => { setShowEnrollModal(false); setEnrollmentResult(null); }} onClick={() => { setShowEnrollModal(false); setEnrollmentResult(null); }}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Check, X, UserCheck, UserX, Shield, Trash2 } from 'lucide-react'; import { Check, X, UserCheck, UserX, Shield, Trash2, Car } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Loading } from '@/components/Loading'; import { Loading } from '@/components/Loading';
@@ -12,6 +12,10 @@ interface User {
role: string; role: string;
isApproved: boolean; isApproved: boolean;
createdAt: string; createdAt: string;
driver?: {
id: string;
deletedAt: string | null;
} | null;
} }
export function UserList() { export function UserList() {
@@ -72,6 +76,21 @@ export function UserList() {
}, },
}); });
const toggleDriverMutation = useMutation({
mutationFn: async ({ userId, isAlsoDriver }: { userId: string; isAlsoDriver: boolean }) => {
await api.patch(`/users/${userId}`, { isAlsoDriver });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['drivers'] });
toast.success('Driver status updated');
},
onError: (error: any) => {
console.error('[USERS] Failed to toggle driver status:', error);
toast.error(error.response?.data?.message || 'Failed to update driver status');
},
});
const handleRoleChange = (userId: string, newRole: string) => { const handleRoleChange = (userId: string, newRole: string) => {
if (confirm(`Change user role to ${newRole}?`)) { if (confirm(`Change user role to ${newRole}?`)) {
changeRoleMutation.mutate({ userId, role: newRole }); changeRoleMutation.mutate({ userId, role: newRole });
@@ -201,6 +220,9 @@ export function UserList() {
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Role Role
</th> </th>
<th className="px-6 py-3 text-center text-xs font-medium text-muted-foreground uppercase">
Driver
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase"> <th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
Status Status
</th> </th>
@@ -210,7 +232,11 @@ export function UserList() {
</tr> </tr>
</thead> </thead>
<tbody className="bg-card divide-y divide-border"> <tbody className="bg-card divide-y divide-border">
{approvedUsers.map((user) => ( {approvedUsers.map((user) => {
const hasDriver = !!(user.driver && !user.driver.deletedAt);
const isDriverRole = user.role === 'DRIVER';
return (
<tr key={user.id} className="hover:bg-accent transition-colors"> <tr key={user.id} className="hover:bg-accent transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
{user.name || 'Unknown User'} {user.name || 'Unknown User'}
@@ -232,6 +258,24 @@ export function UserList() {
{user.role} {user.role}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<label className="inline-flex items-center gap-1.5 cursor-pointer" title={
isDriverRole
? 'Inherent to DRIVER role'
: hasDriver
? 'Remove from driver list'
: 'Add to driver list'
}>
<input
type="checkbox"
checked={hasDriver || isDriverRole}
disabled={isDriverRole || toggleDriverMutation.isPending}
onChange={() => toggleDriverMutation.mutate({ userId: user.id, isAlsoDriver: !hasDriver })}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-50"
/>
{hasDriver && <Car className="h-3.5 w-3.5 text-muted-foreground" />}
</label>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400 font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400 font-medium">
<Check className="h-4 w-4 inline mr-1" /> <Check className="h-4 w-4 inline mr-1" />
Active Active
@@ -257,7 +301,8 @@ export function UserList() {
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -37,7 +37,7 @@ const VEHICLE_STATUS = [
{ value: 'RESERVED', label: 'Reserved', color: 'yellow' }, { value: 'RESERVED', label: 'Reserved', color: 'yellow' },
]; ];
export function VehicleList() { export function VehicleList({ embedded = false }: { embedded?: boolean }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null); const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
@@ -189,6 +189,7 @@ export function VehicleList() {
return ( return (
<div> <div>
{!embedded ? (
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-foreground">Vehicle Management</h1> <h1 className="text-3xl font-bold text-foreground">Vehicle Management</h1>
<button <button
@@ -199,6 +200,17 @@ export function VehicleList() {
{showForm ? 'Cancel' : 'Add Vehicle'} {showForm ? 'Cancel' : 'Add Vehicle'}
</button> </button>
</div> </div>
) : (
<div className="flex justify-end mb-4">
<button
onClick={() => setShowForm(!showForm)}
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
>
<Plus className="h-5 w-5 mr-2" />
{showForm ? 'Cancel' : 'Add Vehicle'}
</button>
</div>
)}
{/* Stats Summary */} {/* Stats Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">

View File

@@ -83,6 +83,7 @@ export interface EnrollmentResponse {
success: boolean; success: boolean;
deviceIdentifier: string; deviceIdentifier: string;
serverUrl: string; serverUrl: string;
qrCodeUrl: string;
instructions: string; instructions: string;
signalMessageSent?: boolean; signalMessageSent?: boolean;
} }