Compare commits
5 Commits
1e162b4f7c
...
858793d698
| Author | SHA1 | Date | |
|---|---|---|---|
| 858793d698 | |||
| 16c0fb65a6 | |||
| 42bab25766 | |||
| ec7c5a6802 | |||
| a0d0cbc8f6 |
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal 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 |
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
54
frontend/src/pages/FleetPage.tsx
Normal file
54
frontend/src/pages/FleetPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user