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>
This commit is contained in:
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user