Compare commits
5 Commits
1e162b4f7c
...
858793d698
| Author | SHA1 | Date | |
|---|---|---|---|
| 858793d698 | |||
| 16c0fb65a6 | |||
| 42bab25766 | |||
| ec7c5a6802 | |||
| a0d0cbc8f6 |
@@ -5,6 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
@@ -27,6 +28,7 @@ export class GpsService implements OnModuleInit {
|
||||
private prisma: PrismaService,
|
||||
private traccarClient: TraccarClientService,
|
||||
private signalService: SignalService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -141,6 +143,7 @@ export class GpsService implements OnModuleInit {
|
||||
success: boolean;
|
||||
deviceIdentifier: string;
|
||||
serverUrl: string;
|
||||
qrCodeUrl: string;
|
||||
instructions: string;
|
||||
signalMessageSent?: boolean;
|
||||
}> {
|
||||
@@ -192,6 +195,19 @@ export class GpsService implements OnModuleInit {
|
||||
const serverUrl = this.traccarClient.getDeviceServerUrl();
|
||||
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 = `
|
||||
GPS Tracking Setup Instructions for ${driver.name}:
|
||||
|
||||
@@ -234,6 +250,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
||||
success: true,
|
||||
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
|
||||
serverUrl,
|
||||
qrCodeUrl,
|
||||
instructions,
|
||||
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';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@@ -9,4 +9,8 @@ export class UpdateUserDto {
|
||||
@IsEnum(Role)
|
||||
@IsOptional()
|
||||
role?: Role;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isAlsoDriver?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,36 +35,52 @@ export class UsersService {
|
||||
|
||||
this.logger.log(`Updating user ${id}: ${JSON.stringify(updateUserDto)}`);
|
||||
|
||||
// Handle role change and Driver record synchronization
|
||||
if (updateUserDto.role && updateUserDto.role !== user.role) {
|
||||
// If changing TO DRIVER role, create a Driver record if one doesn't exist
|
||||
if (updateUserDto.role === Role.DRIVER && !user.driver) {
|
||||
this.logger.log(
|
||||
`Creating Driver record for user ${user.email} (role change to DRIVER)`,
|
||||
);
|
||||
await this.prisma.driver.create({
|
||||
data: {
|
||||
name: user.name || user.email,
|
||||
phone: user.email, // Use email as placeholder for phone
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
const { isAlsoDriver, ...prismaData } = updateUserDto;
|
||||
const effectiveRole = updateUserDto.role || user.role;
|
||||
|
||||
// If changing FROM DRIVER role to something else, remove the Driver record
|
||||
if (user.role === Role.DRIVER && updateUserDto.role !== Role.DRIVER && user.driver) {
|
||||
this.logger.log(
|
||||
`Removing Driver record for user ${user.email} (role change from DRIVER to ${updateUserDto.role})`,
|
||||
);
|
||||
await this.prisma.driver.delete({
|
||||
where: { id: user.driver.id },
|
||||
});
|
||||
}
|
||||
// Handle role change to DRIVER: auto-create driver record
|
||||
if (updateUserDto.role === Role.DRIVER && !user.driver) {
|
||||
this.logger.log(
|
||||
`Creating Driver record for user ${user.email} (role change to DRIVER)`,
|
||||
);
|
||||
await this.prisma.driver.create({
|
||||
data: {
|
||||
name: user.name || user.email,
|
||||
phone: user.email,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// When promoting FROM DRIVER to Admin/Coordinator, keep the driver record
|
||||
// (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(
|
||||
`Creating Driver record for user ${user.email} (isAlsoDriver toggled on)`,
|
||||
);
|
||||
await this.prisma.driver.create({
|
||||
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({
|
||||
where: { id: user.id },
|
||||
data: updateUserDto,
|
||||
data: prismaData,
|
||||
include: { driver: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
|
||||
# Traccar GPS Tracking Server
|
||||
traccar:
|
||||
image: traccar/traccar:latest
|
||||
image: traccar/traccar:6.11
|
||||
container_name: vip-traccar
|
||||
ports:
|
||||
- "8082:8082" # Web UI & API
|
||||
|
||||
@@ -46,7 +46,7 @@ EXPOSE 80
|
||||
|
||||
# Health check
|
||||
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
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>VIP Coordinator</title>
|
||||
<!-- 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 { VIPList } from '@/pages/VipList';
|
||||
import { VIPSchedule } from '@/pages/VIPSchedule';
|
||||
import { DriverList } from '@/pages/DriverList';
|
||||
import { VehicleList } from '@/pages/VehicleList';
|
||||
import { FleetPage } from '@/pages/FleetPage';
|
||||
import { EventList } from '@/pages/EventList';
|
||||
import { FlightList } from '@/pages/FlightList';
|
||||
import { UserList } from '@/pages/UserList';
|
||||
@@ -115,8 +114,9 @@ function App() {
|
||||
<Route path="/command-center" element={<CommandCenter />} />
|
||||
<Route path="/vips" element={<VIPList />} />
|
||||
<Route path="/vips/:id/schedule" element={<VIPSchedule />} />
|
||||
<Route path="/drivers" element={<DriverList />} />
|
||||
<Route path="/vehicles" element={<VehicleList />} />
|
||||
<Route path="/fleet" element={<FleetPage />} />
|
||||
<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="/flights" element={<FlightList />} />
|
||||
<Route path="/users" element={<UserList />} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Plane,
|
||||
Users,
|
||||
Car,
|
||||
Truck,
|
||||
Calendar,
|
||||
UserCog,
|
||||
LayoutDashboard,
|
||||
@@ -71,8 +70,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
{ name: 'My Profile', href: '/profile', icon: UserCog, driverOnly: 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: 'Drivers', href: '/drivers', icon: Car, requireRead: 'Driver' as const, coordinatorOnly: true },
|
||||
{ name: 'Vehicles', href: '/vehicles', icon: Truck, requireRead: 'Vehicle' as const, coordinatorOnly: true },
|
||||
{ name: 'Fleet', href: '/fleet', icon: Car, requireRead: 'Driver' 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 },
|
||||
];
|
||||
|
||||
@@ -74,6 +74,7 @@ export function useGpsDevices() {
|
||||
const { data } = await api.get('/gps/devices');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: 30000, // Refresh every 30 seconds to update lastActive
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ export function CommandCenter() {
|
||||
alerts.push({
|
||||
type: 'warning',
|
||||
message: 'No vehicles available - all vehicles in use',
|
||||
link: `/vehicles`,
|
||||
link: `/fleet?tab=vehicles`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -902,7 +902,7 @@ export function CommandCenter() {
|
||||
Events
|
||||
</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"
|
||||
>
|
||||
<Users className="h-3 w-3" />
|
||||
@@ -916,7 +916,7 @@ export function CommandCenter() {
|
||||
VIPs
|
||||
</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"
|
||||
>
|
||||
<Car className="h-3 w-3" />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DriverChatModal } from '@/components/DriverChatModal';
|
||||
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
||||
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
||||
|
||||
export function DriverList() {
|
||||
export function DriverList({ embedded = false }: { embedded?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingDriver, setEditingDriver] = useState<Driver | null>(null);
|
||||
@@ -234,17 +234,19 @@ export function DriverList() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
disabled
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Driver
|
||||
</button>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<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>
|
||||
<button
|
||||
disabled
|
||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Add Driver
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden lg:block">
|
||||
<TableSkeleton rows={8} />
|
||||
</div>
|
||||
@@ -257,17 +259,30 @@ export function DriverList() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-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>
|
||||
{!embedded ? (
|
||||
<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>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="w-full sm:w-auto inline-flex items-center justify-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>
|
||||
) : (
|
||||
<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 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Manual Configuration - Traccar Client doesn't reliably scan QR codes */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/30 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Smartphone className="h-5 w-5 text-blue-600" />
|
||||
{/* QR Code - scan with Traccar Client app to auto-configure */}
|
||||
<div className="bg-white dark:bg-gray-900 p-4 rounded-lg border border-border text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center mb-3">
|
||||
<div className="flex gap-2 justify-center">
|
||||
<a
|
||||
href="https://apps.apple.com/app/traccar-client/id843156974"
|
||||
target="_blank"
|
||||
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
|
||||
</a>
|
||||
@@ -787,56 +806,54 @@ export function GpsTracking() {
|
||||
href="https://play.google.com/store/apps/details?id=org.traccar.client"
|
||||
target="_blank"
|
||||
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
|
||||
</a>
|
||||
</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 className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Device ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
|
||||
{enrollmentResult.deviceIdentifier}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(enrollmentResult.deviceIdentifier)}
|
||||
className="p-2 hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 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>
|
||||
<label className="text-xs text-muted-foreground">Device ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono">
|
||||
{enrollmentResult.deviceIdentifier}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(enrollmentResult.deviceIdentifier)}
|
||||
className="p-2 hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Server URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
|
||||
{enrollmentResult.serverUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(enrollmentResult.serverUrl)}
|
||||
className="p-2 hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Server URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted p-2 rounded text-sm font-mono text-xs">
|
||||
{enrollmentResult.serverUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(enrollmentResult.serverUrl)}
|
||||
className="p-2 hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</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>
|
||||
<li>Set frequency to {settings?.updateIntervalSeconds || 60} seconds</li>
|
||||
<li>Tap "Service Status" to start tracking</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>Tap "Service Status" to start tracking</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowEnrollModal(false); setEnrollmentResult(null); }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
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 { Loading } from '@/components/Loading';
|
||||
|
||||
@@ -12,6 +12,10 @@ interface User {
|
||||
role: string;
|
||||
isApproved: boolean;
|
||||
createdAt: string;
|
||||
driver?: {
|
||||
id: string;
|
||||
deletedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (confirm(`Change user role to ${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">
|
||||
Role
|
||||
</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">
|
||||
Status
|
||||
</th>
|
||||
@@ -210,7 +232,11 @@ export function UserList() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||
{user.name || 'Unknown User'}
|
||||
@@ -232,6 +258,24 @@ export function UserList() {
|
||||
{user.role}
|
||||
</span>
|
||||
</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">
|
||||
<Check className="h-4 w-4 inline mr-1" />
|
||||
Active
|
||||
@@ -257,7 +301,8 @@ export function UserList() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ const VEHICLE_STATUS = [
|
||||
{ value: 'RESERVED', label: 'Reserved', color: 'yellow' },
|
||||
];
|
||||
|
||||
export function VehicleList() {
|
||||
export function VehicleList({ embedded = false }: { embedded?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
|
||||
@@ -189,16 +189,28 @@ export function VehicleList() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground">Vehicle Management</h1>
|
||||
<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>
|
||||
{!embedded ? (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground">Vehicle Management</h1>
|
||||
<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>
|
||||
) : (
|
||||
<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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface EnrollmentResponse {
|
||||
success: boolean;
|
||||
deviceIdentifier: string;
|
||||
serverUrl: string;
|
||||
qrCodeUrl: string;
|
||||
instructions: string;
|
||||
signalMessageSent?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user