implement freight permissions for trains, wagons, and routes

This commit is contained in:
Marshal
2026-07-23 05:57:14 +00:00
parent 2481f43f1f
commit 129a4d1d0a
19 changed files with 369 additions and 237 deletions

View File

@@ -29,9 +29,22 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () => export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage); BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); /**
* Fleet guards take an optional granular per-resource key (locomotives:create,
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working.
*/
export const FleetView = (granular?: string) =>
BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); export const FleetManage = (granular?: string) =>
BookingStaff(
granular
? [granular, FREIGHT_PERMS.fleet.manage]
: FREIGHT_PERMS.fleet.manage,
);
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */ /** Requester creates a wagon-transfer request (count-only, no wagon picks). */
export const WagonTransferRequest = () => export const WagonTransferRequest = () =>

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards'; import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateCargoDto } from './dto/create-cargo.dto'; import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service';
@ApiTags('cargoes') @ApiTags('cargoes')
@Controller('cargoes') @Controller('cargoes')
@FleetView() @FleetView(FREIGHT_PERMS.cargoes.view)
export class CargoesController { export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {} constructor(private readonly cargoesService: CargoesService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.create)
@ApiOperation({ summary: 'Create a new cargo' }) @ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) { create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto); return this.cargoesService.create(dto);
@@ -43,35 +44,35 @@ export class CargoesController {
} }
@Patch(':id') @Patch(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Update a cargo' }) @ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto); return this.cargoesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.delete)
@ApiOperation({ summary: 'Delete a cargo' }) @ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id); return this.cargoesService.remove(id);
} }
@Post(':id/load') @Post(':id/load')
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Load cargo into a container' }) @ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto); return this.cargoesService.loadCargo(id, dto);
} }
@Post(':id/unload') @Post(':id/unload')
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Unload cargo from container' }) @ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) { unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id); return this.cargoesService.unloadCargo(id);
} }
@Post(':id/deliver') @Post(':id/deliver')
@FleetManage() @FleetManage(FREIGHT_PERMS.cargoes.update)
@ApiOperation({ summary: 'Mark cargo as delivered' }) @ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto); return this.cargoesService.deliverCargo(id, dto);

View File

@@ -10,18 +10,19 @@ import {
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards"; import { FleetManage, FleetView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ConsignmentsService } from "./consignments.service"; import { ConsignmentsService } from "./consignments.service";
import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { CreateConsignmentDto } from "./dto/create-consignment.dto";
import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
@ApiTags("consignments") @ApiTags("consignments")
@Controller("consignments") @Controller("consignments")
@FleetView() @FleetView(FREIGHT_PERMS.consignments.view)
export class ConsignmentsController { export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {} constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.consignments.create)
@ApiOperation({ summary: "Create a new consignment" }) @ApiOperation({ summary: "Create a new consignment" })
create(@Body() dto: CreateConsignmentDto) { create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto); return this.consignmentsService.create(dto);

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards'; import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateContainerDto } from './dto/create-container.dto'; import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -18,12 +19,12 @@ import { ContainersService } from './containers.service';
@ApiTags('containers') @ApiTags('containers')
@Controller('containers') @Controller('containers')
@FleetView() @FleetView(FREIGHT_PERMS.containers.view)
export class ContainersController { export class ContainersController {
constructor(private readonly containersService: ContainersService) {} constructor(private readonly containersService: ContainersService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.containers.create)
@ApiOperation({ summary: 'Create a new container' }) @ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) { create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto); return this.containersService.create(dto);
@@ -42,28 +43,28 @@ export class ContainersController {
} }
@Patch(':id') @Patch(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Update a container' }) @ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto); return this.containersService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.containers.delete)
@ApiOperation({ summary: 'Delete a container' }) @ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id); return this.containersService.remove(id);
} }
@Post(':id/assign-wagon') @Post(':id/assign-wagon')
@FleetManage() @FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Assign container to a wagon' }) @ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto); return this.containersService.assignToWagon(id, dto);
} }
@Post(':id/unassign-wagon') @Post(':id/unassign-wagon')
@FleetManage() @FleetManage(FREIGHT_PERMS.containers.update)
@ApiOperation({ summary: 'Unassign container from wagon' }) @ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id); return this.containersService.unassignFromWagon(id);

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, StaffReference } from '../../common/booking-guards'; import { FleetManage, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -31,21 +32,21 @@ export class LocomotivesController {
} }
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.locomotives.create)
@ApiOperation({ summary: 'Create a locomotive' }) @ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) { create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto); return this.locomotivesService.create(dto);
} }
@Patch(':id') @Patch(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.locomotives.update)
@ApiOperation({ summary: 'Update a locomotive' }) @ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto); return this.locomotivesService.update(id, dto);
} }
@Post(':id/decommission') @Post(':id/decommission')
@FleetManage() @FleetManage(FREIGHT_PERMS.locomotives.delete)
@ApiOperation({ summary: 'Decommission a locomotive' }) @ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) { decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id); return this.locomotivesService.decommission(id);

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards'; import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateRouteDto } from './dto/create-route.dto'; import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto'; import { UpdateRouteDto } from './dto/update-route.dto';
@@ -10,7 +11,7 @@ import { RoutesService } from './routes.service';
@ApiTags('routes') @ApiTags('routes')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('routes') @Controller('routes')
@FleetView() @FleetView(FREIGHT_PERMS.routes.view)
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@@ -27,21 +28,21 @@ export class RoutesController {
} }
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.routes.create)
@ApiOperation({ summary: 'Create route' }) @ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) { create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto); return this.routesService.create(dto);
} }
@Patch(':id') @Patch(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.routes.update)
@ApiOperation({ summary: 'Update route' }) @ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto); return this.routesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.routes.delete)
@ApiOperation({ summary: 'Deactivate route' }) @ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id); return this.routesService.deactivate(id);

View File

@@ -15,6 +15,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards'; import { FleetManage, FleetView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto'; import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
@@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder') @ApiTags('train-builder')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('train-builder') @Controller('train-builder')
@FleetView() @FleetView(FREIGHT_PERMS.trains.view)
export class TrainBuilderController { export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {} constructor(private readonly trainBuilderService: TrainBuilderService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.create)
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' }) @ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
build(@Body() dto: BuildTrainDto) { build(@Body() dto: BuildTrainDto) {
return this.trainBuilderService.buildTrain(dto); return this.trainBuilderService.buildTrain(dto);
@@ -60,7 +61,7 @@ export class TrainBuilderController {
} }
@Put(':id/locomotives') @Put(':id/locomotives')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
setLocomotives( setLocomotives(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -70,7 +71,7 @@ export class TrainBuilderController {
} }
@Patch(':id/details') @Patch(':id/details')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ @ApiOperation({
summary: "Edit the train's name and fixed import/export run numbers", summary: "Edit the train's name and fixed import/export run numbers",
}) })
@@ -82,7 +83,7 @@ export class TrainBuilderController {
} }
@Patch(':id/yard') @Patch(':id/yard')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ @ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
}) })
@@ -91,14 +92,14 @@ export class TrainBuilderController {
} }
@Post(':id/wagons') @Post(':id/wagons')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto); return this.trainBuilderService.assignWagons(id, dto);
} }
@Delete(':id/wagons/:wagonId') @Delete(':id/wagons/:wagonId')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon from the consist' }) @ApiOperation({ summary: 'Detach one wagon from the consist' })
removeWagon( removeWagon(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -108,7 +109,7 @@ export class TrainBuilderController {
} }
@Post(':id/wagons/:wagonId/maintenance') @Post(':id/wagons/:wagonId/maintenance')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
sendWagonToMaintenance( sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -118,14 +119,14 @@ export class TrainBuilderController {
} }
@Post(':id/reorder-wagons') @Post(':id/reorder-wagons')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) { reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
return this.trainBuilderService.reorderWagons(id, dto); return this.trainBuilderService.reorderWagons(id, dto);
} }
@Post(':id/deactivate') @Post(':id/deactivate')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ @ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule', summary: 'Deactivate the train (park it) — only allowed with no active schedule',
}) })
@@ -134,14 +135,14 @@ export class TrainBuilderController {
} }
@Post(':id/activate') @Post(':id/activate')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) { activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id); return this.trainBuilderService.activate(id);
} }
@Delete(':id') @Delete(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.delete)
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) { disband(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -12,18 +12,19 @@ import {
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FleetManage, FleetView } from "../../common/booking-guards"; import { FleetManage, FleetView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateTrainDto } from "./dto/create-train.dto"; import { CreateTrainDto } from "./dto/create-train.dto";
import { UpdateTrainDto } from "./dto/update-train.dto"; import { UpdateTrainDto } from "./dto/update-train.dto";
import { TrainsService } from "./trains.service"; import { TrainsService } from "./trains.service";
@ApiTags("trains") @ApiTags("trains")
@Controller("trains") @Controller("trains")
@FleetView() @FleetView(FREIGHT_PERMS.trains.view)
export class TrainsController { export class TrainsController {
constructor(private readonly trainsService: TrainsService) {} constructor(private readonly trainsService: TrainsService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.create)
@ApiOperation({ summary: "Register a new train" }) @ApiOperation({ summary: "Register a new train" })
create(@Body() dto: CreateTrainDto) { create(@Body() dto: CreateTrainDto) {
return this.trainsService.create(dto); return this.trainsService.create(dto);
@@ -42,14 +43,14 @@ export class TrainsController {
} }
@Patch(":id") @Patch(":id")
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: "Update a train" }) @ApiOperation({ summary: "Update a train" })
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) {
return this.trainsService.update(id, dto); return this.trainsService.update(id, dto);
} }
@Delete(":id") @Delete(":id")
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.delete)
@ApiOperation({ summary: "Delete a train" }) @ApiOperation({ summary: "Delete a train" })
remove(@Param("id", ParseUUIDPipe) id: string) { remove(@Param("id", ParseUUIDPipe) id: string) {
return this.trainsService.remove(id); return this.trainsService.remove(id);

View File

@@ -19,6 +19,7 @@ import {
WagonTransferHistoryAll, WagonTransferHistoryAll,
WagonTransferRequest, WagonTransferRequest,
} from '../../common/booking-guards'; } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
@@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
*/ */
@ApiTags('wagon-transfer-requests') @ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests') @Controller('wagon-transfer-requests')
@FleetView() @FleetView(FREIGHT_PERMS.wagons.view)
export class WagonTransferRequestsController { export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {} constructor(private readonly service: WagonTransferRequestsService) {}
@@ -110,7 +111,7 @@ export class WagonTransferRequestsController {
} }
@Post(':id/cancel') @Post(':id/cancel')
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.transferRequest)
@ApiOperation({ summary: 'Withdraw a pending transfer request' }) @ApiOperation({ summary: 'Withdraw a pending transfer request' })
cancel(@Param('id', ParseUUIDPipe) id: string) { cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancelRequest(id); return this.service.cancelRequest(id);

View File

@@ -13,6 +13,7 @@ import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards'; import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto'; import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -31,7 +32,7 @@ export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {} constructor(private readonly wagonsService: WagonsService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.create)
@ApiOperation({ summary: 'Create a new wagon' }) @ApiOperation({ summary: 'Create a new wagon' })
create(@Body() dto: CreateWagonDto) { create(@Body() dto: CreateWagonDto) {
return this.wagonsService.create(dto); return this.wagonsService.create(dto);
@@ -61,42 +62,42 @@ export class WagonsController {
} }
@Patch(':id') @Patch(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Update a wagon' }) @ApiOperation({ summary: 'Update a wagon' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
return this.wagonsService.update(id, dto); return this.wagonsService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.delete)
@ApiOperation({ summary: 'Delete a wagon' }) @ApiOperation({ summary: 'Delete a wagon' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.remove(id); return this.wagonsService.remove(id);
} }
@Post(':id/assign-train') @Post(':id/assign-train')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Assign wagon to a train' }) @ApiOperation({ summary: 'Assign wagon to a train' })
assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
return this.wagonsService.assignToTrain(id, dto); return this.wagonsService.assignToTrain(id, dto);
} }
@Post(':id/unassign-train') @Post(':id/unassign-train')
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Unassign wagon from train' }) @ApiOperation({ summary: 'Unassign wagon from train' })
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id); return this.wagonsService.unassignFromTrain(id);
} }
@Post('bulk-transfer') @Post('bulk-transfer')
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkTransfer(dto, user?.id); return this.wagonsService.bulkTransfer(dto, user?.id);
} }
@Post('bulk-status') @Post('bulk-status')
@FleetManage() @FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Set the status of multiple wagons' }) @ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto); return this.wagonsService.bulkSetStatus(dto);
@@ -105,12 +106,12 @@ export class WagonsController {
// Separate controller for trainspecific reorder (registered in module) // Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons') @Controller('trains/:trainId/reorder-wagons')
@FleetView() @FleetView(FREIGHT_PERMS.trains.view)
export class TrainWagonsReorderController { export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {} constructor(private readonly wagonsService: WagonsService) {}
@Post() @Post()
@FleetManage() @FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Reorder wagons of a train' }) @ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto); return this.wagonsService.reorderWagons(trainId, dto);

View File

@@ -212,6 +212,8 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
perm('e1g00001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'),
perm('e1g00001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'),
]; ];
// G. Fleet — road & telemetry // G. Fleet — road & telemetry
@@ -493,6 +495,10 @@ export const FREIGHT_PERMS = {
update: 'edr_freight_app:cargoes:update', update: 'edr_freight_app:cargoes:update',
delete: 'edr_freight_app:cargoes:delete', delete: 'edr_freight_app:cargoes:delete',
}, },
consignments: {
view: 'edr_freight_app:consignments:view',
create: 'edr_freight_app:consignments:create',
},
vehicles: { vehicles: {
view: 'edr_freight_app:vehicles:view', view: 'edr_freight_app:vehicles:view',
create: 'edr_freight_app:vehicles:create', create: 'edr_freight_app:vehicles:create',

View File

@@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Routes", label: "Routes",
href: "/dashboard/routes", href: "/dashboard/routes",
icon: <Network />, icon: <Network />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
}, },
{ {
label: "Locomotives", label: "Locomotives",
href: "/dashboard/locomotives", href: "/dashboard/locomotives",
icon: <Train />, icon: <Train />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
}, },
{ {
label: "Train Builder", label: "Train Builder",
href: "/dashboard/train-builder", href: "/dashboard/train-builder",
icon: <Hammer />, icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
}, },
// { // {
@@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Wagons", label: "Wagons",
href: "/dashboard/wagons", href: "/dashboard/wagons",
icon: <Truck />, icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view, permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
}, },
{ {
label: "Vehicles", label: "Vehicles",
@@ -1111,7 +1111,7 @@ const App = () => {
<Route <Route
path="routes" path="routes"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
<RoutesPage /> <RoutesPage />
</RequirePermission> </RequirePermission>
} }
@@ -1119,7 +1119,7 @@ const App = () => {
<Route <Route
path="locomotives" path="locomotives"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1127,7 +1127,7 @@ const App = () => {
<Route <Route
path="trains" path="trains"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1135,7 +1135,7 @@ const App = () => {
<Route <Route
path="trains/:id" path="trains/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainDetailPage /> <TrainDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -1143,7 +1143,7 @@ const App = () => {
<Route <Route
path="train-builder" path="train-builder"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderListPage /> <TrainBuilderListPage />
</RequirePermission> </RequirePermission>
} }
@@ -1151,7 +1151,7 @@ const App = () => {
<Route <Route
path="train-builder/:id" path="train-builder/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderDetailPage /> <TrainBuilderDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -1159,7 +1159,7 @@ const App = () => {
<Route <Route
path="wagons" path="wagons"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1167,7 +1167,7 @@ const App = () => {
<Route <Route
path="containers" path="containers"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1175,7 +1175,7 @@ const App = () => {
<Route <Route
path="cargoes" path="cargoes"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1261,7 +1261,7 @@ const App = () => {
<Route <Route
path="routes" path="routes"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
<RoutesPage /> <RoutesPage />
</RequirePermission> </RequirePermission>
} }
@@ -1349,7 +1349,7 @@ const App = () => {
<Route <Route
path="locomotives" path="locomotives"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1357,7 +1357,7 @@ const App = () => {
<Route <Route
path="trains" path="trains"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1365,7 +1365,7 @@ const App = () => {
<Route <Route
path="trains/:id" path="trains/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainDetailPage /> <TrainDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -1373,7 +1373,7 @@ const App = () => {
<Route <Route
path="train-builder" path="train-builder"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderListPage /> <TrainBuilderListPage />
</RequirePermission> </RequirePermission>
} }
@@ -1381,7 +1381,7 @@ const App = () => {
<Route <Route
path="train-builder/:id" path="train-builder/:id"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
<TrainBuilderDetailPage /> <TrainBuilderDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -1389,7 +1389,7 @@ const App = () => {
<Route <Route
path="wagons" path="wagons"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1397,7 +1397,7 @@ const App = () => {
<Route <Route
path="containers" path="containers"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }
@@ -1405,7 +1405,7 @@ const App = () => {
<Route <Route
path="cargoes" path="cargoes"
element={ element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}> <RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
<FleetResourcePage /> <FleetResourcePage />
</RequirePermission> </RequirePermission>
} }

View File

@@ -19,8 +19,9 @@ export interface FleetCardGridProps {
pageCount: number; pageCount: number;
totalCount: number; totalCount: number;
onPaginationChange: OnChangeFn<PaginationState>; onPaginationChange: OnChangeFn<PaginationState>;
onEdit: (record: FleetRecord) => void; /** Omit to hide the action (caller lacks the update/delete permission). */
onRemove: (record: FleetRecord) => void; onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
} }
const FleetCardGrid = ({ const FleetCardGrid = ({

View File

@@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetRecordActionsProps { export interface FleetRecordActionsProps {
record: FleetRecord; record: FleetRecord;
config: FleetResourceConfig; config: FleetResourceConfig;
onEdit: (record: FleetRecord) => void; /** Omit to hide the action (caller lacks the update/delete permission). */
onRemove: (record: FleetRecord) => void; onEdit?: (record: FleetRecord) => void;
onRemove?: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void; onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void; onHistory?: (record: FleetRecord) => void;
onViewDetail?: (record: FleetRecord) => void; onViewDetail?: (record: FleetRecord) => void;
@@ -42,6 +43,17 @@ const FleetRecordActions = ({
navigate(config.detailPath.replace(":id", String(record.id))); navigate(config.detailPath.replace(":id", String(record.id)));
}; };
if (
!onEdit &&
!onRemove &&
!showDetail &&
!showViewDetail &&
!showHistory &&
!(isVehicle && onAssignDriver)
) {
return null;
}
if (layout === "compact") { if (layout === "compact") {
return ( return (
<Menu position="bottom-end" withinPortal shadow="md"> <Menu position="bottom-end" withinPortal shadow="md">
@@ -61,12 +73,14 @@ const FleetRecordActions = ({
Assign Driver Assign Driver
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem {onEdit ? (
onClick={() => onEdit(record)} <MenuItem
leftSection={<Edit2 size={14} strokeWidth={2} />} onClick={() => onEdit(record)}
> leftSection={<Edit2 size={14} strokeWidth={2} />}
Edit >
</MenuItem> Edit
</MenuItem>
) : null}
{showViewDetail ? ( {showViewDetail ? (
<MenuItem <MenuItem
onClick={() => onViewDetail?.(record)} onClick={() => onViewDetail?.(record)}
@@ -91,13 +105,15 @@ const FleetRecordActions = ({
View details View details
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem {onRemove ? (
color="red" <MenuItem
onClick={() => onRemove(record)} color="red"
leftSection={<Trash2 size={14} strokeWidth={2} />} onClick={() => onRemove(record)}
> leftSection={<Trash2 size={14} strokeWidth={2} />}
{removeLabel} >
</MenuItem> {removeLabel}
</MenuItem>
) : null}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );
@@ -121,12 +137,14 @@ const FleetRecordActions = ({
Assign Driver Assign Driver
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem {onEdit ? (
onClick={() => onEdit(record)} <MenuItem
leftSection={<Edit2 size={14} strokeWidth={2} />} onClick={() => onEdit(record)}
> leftSection={<Edit2 size={14} strokeWidth={2} />}
Edit >
</MenuItem> Edit
</MenuItem>
) : null}
{showViewDetail ? ( {showViewDetail ? (
<MenuItem <MenuItem
onClick={() => onViewDetail?.(record)} onClick={() => onViewDetail?.(record)}
@@ -143,13 +161,15 @@ const FleetRecordActions = ({
View details View details
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem {onRemove ? (
color="red" <MenuItem
onClick={() => onRemove(record)} color="red"
leftSection={<Trash2 size={14} strokeWidth={2} />} onClick={() => onRemove(record)}
> leftSection={<Trash2 size={14} strokeWidth={2} />}
{removeLabel} >
</MenuItem> {removeLabel}
</MenuItem>
) : null}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );

View File

@@ -507,6 +507,31 @@ export function canViewFleet(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.fleet.view); return hasPermission(user, FREIGHT_PERMS.fleet.view);
} }
export type FleetCrudResource =
| "locomotives"
| "wagons"
| "trains"
| "routes"
| "containers"
| "cargoes"
| "vehicles"
| "drivers";
/**
* Per-resource fleet CRUD check. The legacy coarse fleet:manage key still
* grants every action (mirrors the API's one-of guard fallback).
*/
export function canFleetAction(
user: AuthUser | null | undefined,
resource: FleetCrudResource,
action: "create" | "update" | "delete",
): boolean {
return (
hasPermission(user, FREIGHT_PERMS[resource][action]) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage)
);
}
export function isFreightAdmin(user: AuthUser | null | undefined): boolean { export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.admin); return hasPermission(user, FREIGHT_PERMS.admin);
} }

View File

@@ -3,6 +3,8 @@ import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Inbox, Plus, Warehouse } from "lucide-react"; import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -37,6 +39,10 @@ const FleetResourcePage = () => {
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug); const config = getFleetResource(slug);
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth();
const canCreate = canFleetAction(user, slug, "create");
const canUpdate = canFleetAction(user, slug, "update");
const canDelete = canFleetAction(user, slug, "delete");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -278,12 +284,16 @@ const FleetResourcePage = () => {
record={row.original} record={row.original}
config={config} config={config}
layout="compact" layout="compact"
onEdit={(record) => { onEdit={
setEditing(record); canUpdate
setFormOpen(true); ? (record) => {
}} setEditing(record);
onRemove={setRemoveTarget} setFormOpen(true);
onAssignDriver={setAssigningDriver} }
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
onHistory={setHistoryTarget} onHistory={setHistoryTarget}
/> />
</div> </div>
@@ -291,7 +301,7 @@ const FleetResourcePage = () => {
}); });
return base; return base;
}, [config, dynamicOptions.yards]); }, [config, dynamicOptions.yards, canUpdate, canDelete]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -390,15 +400,17 @@ const FleetResourcePage = () => {
<Group gap="sm"> <Group gap="sm">
{slug === "wagons" ? ( {slug === "wagons" ? (
<> <>
<Button {canUpdate ? (
variant="light" <Button
color="edr-green" variant="light"
leftSection={<Warehouse size={16} />} color="edr-green"
styles={{ label: { fontWeight: 500 } }} leftSection={<Warehouse size={16} />}
onClick={() => setWagonWorkspaceOpen(true)} styles={{ label: { fontWeight: 500 } }}
> onClick={() => setWagonWorkspaceOpen(true)}
Yard Workspace >
</Button> Yard Workspace
</Button>
) : null}
<Button <Button
variant="light" variant="light"
color="grape" color="grape"
@@ -410,12 +422,14 @@ const FleetResourcePage = () => {
</Button> </Button>
</> </>
) : null} ) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => { {canCreate ? (
setEditing(null); <Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setFormOpen(true); setEditing(null);
}}> setFormOpen(true);
{config.addLabel} }}>
</Button> {config.addLabel}
</Button>
) : null}
</Group> </Group>
</Group> </Group>
@@ -529,11 +543,15 @@ const FleetResourcePage = () => {
pageCount={pageCount} pageCount={pageCount}
totalCount={filteredRows.length} totalCount={filteredRows.length}
onPaginationChange={setPagination} onPaginationChange={setPagination}
onEdit={(record) => { onEdit={
setEditing(record); canUpdate
setFormOpen(true); ? (record) => {
}} setEditing(record);
onRemove={setRemoveTarget} setFormOpen(true);
}
: undefined
}
onRemove={canDelete ? setRemoveTarget : undefined}
/> />
)} )}
</Stack> </Stack>

View File

@@ -36,6 +36,8 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service"; import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import {
formatRouteLabel, formatRouteLabel,
@@ -160,6 +162,10 @@ export default function RoutesPage() {
const { viewMode, setViewMode } = useFleetViewMode("routes"); const { viewMode, setViewMode } = useFleetViewMode("routes");
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast(); const { toast } = useToast();
const { user } = useAuth();
const canCreate = canFleetAction(user, "routes", "create");
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
const routesQuery = useQuery(api.routes.list.queryOptions()); const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions()); const yardsQuery = useQuery(api.routes.yards.queryOptions());
@@ -441,26 +447,30 @@ export default function RoutesPage() {
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Edit"> {canUpdate ? (
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}> <Tooltip label="Edit">
<Edit size={16} /> <ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
</ActionIcon> <Edit size={16} />
</Tooltip> </ActionIcon>
<Tooltip label="Mark stop working"> </Tooltip>
<ActionIcon ) : null}
variant="subtle" {canDelete ? (
color="red" <Tooltip label="Mark stop working">
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending} <ActionIcon
onClick={() => handleDeactivate(row.original)} variant="subtle"
> color="red"
<Trash2 size={16} /> disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
</ActionIcon> onClick={() => handleDeactivate(row.original)}
</Tooltip> >
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group> </Group>
), ),
}, },
]; ];
}, [deactivateMutation.isPending]); }, [deactivateMutation.isPending, canUpdate, canDelete]);
return ( return (
<PageContainer> <PageContainer>
@@ -468,9 +478,11 @@ export default function RoutesPage() {
title="Routes" title="Routes"
subtitle="Define rail corridors, segment distances, and operational status for train scheduling." subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
action={ action={
<Button leftSection={<Plus size={18} />} onClick={openCreate}> canCreate ? (
Add route <Button leftSection={<Plus size={18} />} onClick={openCreate}>
</Button> Add route
</Button>
) : undefined
} }
/> />
@@ -555,9 +567,11 @@ export default function RoutesPage() {
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}> <Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View View
</Button> </Button>
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}> {canUpdate ? (
Edit <Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
</Button> Edit
</Button>
) : null}
</Group> </Group>
</Stack> </Stack>
</Card> </Card>

View File

@@ -45,6 +45,8 @@ import {
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => { const parseError = (error: unknown, fallback: string) => {
@@ -76,6 +78,12 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false); const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false); const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false); const [deactivateOpen, setDeactivateOpen] = useState(false);
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
const canAssign =
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage);
const compositionQuery = useQuery( const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -162,59 +170,67 @@ export default function TrainBuilderDetailPage() {
</Group> </Group>
} }
action={ action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}> canUpdate || canDelete ? (
<Menu.Target> <Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}> <Menu.Target>
Actions <Button variant="default" rightSection={<MoreHorizontal size={16} />}>
</Button> Actions
</Menu.Target> </Button>
<Menu.Dropdown> </Menu.Target>
<Menu.Item <Menu.Dropdown>
leftSection={<Replace size={15} />} {canUpdate ? (
disabled={!composition.editable} <>
onClick={() => setLocoModalOpen(true)} <Menu.Item
> leftSection={<Replace size={15} />}
Change locomotives disabled={!composition.editable}
</Menu.Item> onClick={() => setLocoModalOpen(true)}
<Menu.Item >
leftSection={<MapPin size={15} />} Change locomotives
disabled={!composition.editable} </Menu.Item>
onClick={() => setYardModalOpen(true)} <Menu.Item
> leftSection={<MapPin size={15} />}
Change yard disabled={!composition.editable}
</Menu.Item> onClick={() => setYardModalOpen(true)}
{composition.status === "DEACTIVATED" ? ( >
<Menu.Item Change yard
leftSection={<Power size={15} />} </Menu.Item>
disabled={blockingLocomotives.length > 0} {composition.status === "DEACTIVATED" ? (
onClick={() => <Menu.Item
void withToast(async () => { leftSection={<Power size={15} />}
await activate.mutateAsync(composition.id); disabled={blockingLocomotives.length > 0}
toast({ title: `Train ${composition.code} reactivated` }); onClick={() =>
}, "Could not reactivate train") void withToast(async () => {
} await activate.mutateAsync(composition.id);
> toast({ title: `Train ${composition.code} reactivated` });
Reactivate train }, "Could not reactivate train")
</Menu.Item> }
) : ( >
<Menu.Item Reactivate train
leftSection={<PowerOff size={15} />} </Menu.Item>
disabled={composition.activeSchedules.length > 0} ) : (
onClick={() => setDeactivateOpen(true)} <Menu.Item
> leftSection={<PowerOff size={15} />}
Deactivate train disabled={composition.activeSchedules.length > 0}
</Menu.Item> onClick={() => setDeactivateOpen(true)}
)} >
<Menu.Item Deactivate train
color="red" </Menu.Item>
leftSection={<Trash2 size={15} />} )}
disabled={composition.activeSchedules.length > 0} </>
onClick={() => setDisbandOpen(true)} ) : null}
> {canDelete ? (
Disband train <Menu.Item
</Menu.Item> color="red"
</Menu.Dropdown> leftSection={<Trash2 size={15} />}
</Menu> disabled={composition.activeSchedules.length > 0}
onClick={() => setDisbandOpen(true)}
>
Disband train
</Menu.Item>
) : null}
</Menu.Dropdown>
</Menu>
) : undefined
} }
/> />
@@ -259,16 +275,18 @@ export default function TrainBuilderDetailPage() {
. .
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
<Button {canUpdate ? (
size="compact-sm" <Button
variant="light" size="compact-sm"
color="red" variant="light"
leftSection={<Replace size={14} />} color="red"
disabled={!composition.editable} leftSection={<Replace size={14} />}
onClick={() => setLocoModalOpen(true)} disabled={!composition.editable}
> onClick={() => setLocoModalOpen(true)}
Detach & replace locomotives >
</Button> Detach & replace locomotives
</Button>
) : null}
<Button <Button
size="compact-sm" size="compact-sm"
variant="subtle" variant="subtle"
@@ -330,7 +348,7 @@ export default function TrainBuilderDetailPage() {
</Stack> </Stack>
<Grid gap="lg" align="stretch"> <Grid gap="lg" align="stretch">
{composition.editable ? ( {composition.editable && canAssign ? (
<Grid.Col span={{ base: 12, md: 5 }}> <Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%"> <Card h="100%">
<Stack gap="sm"> <Stack gap="sm">
@@ -355,7 +373,7 @@ export default function TrainBuilderDetailPage() {
</Card> </Card>
</Grid.Col> </Grid.Col>
) : null} ) : null}
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}> <Grid.Col span={{ base: 12, md: composition.editable && canAssign ? 7 : 12 }}>
<Card h="100%"> <Card h="100%">
<Stack gap="sm"> <Stack gap="sm">
<Text fw={600}>Wagon order</Text> <Text fw={600}>Wagon order</Text>
@@ -364,7 +382,7 @@ export default function TrainBuilderDetailPage() {
</Text> </Text>
<ConsistWagonList <ConsistWagonList
wagons={composition.wagons} wagons={composition.wagons}
editable={composition.editable} editable={composition.editable && canAssign}
busy={busy} busy={busy}
onReorder={(wagonIds) => onReorder={(wagonIds) =>
void withToast( void withToast(

View File

@@ -35,6 +35,8 @@ import {
trainStatusLabel, trainStatusLabel,
} from "@/components/trainBuilder/trainStatus"; } from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction } from "@/lib/permissions";
import type { import type {
BuiltTrainListFilters, BuiltTrainListFilters,
BuiltTrainStatus, BuiltTrainStatus,
@@ -54,6 +56,9 @@ export default function TrainBuilderListPage() {
const [yardFilter, setYardFilter] = useState("ALL"); const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false); const [buildOpen, setBuildOpen] = useState(false);
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null); const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
const { user } = useAuth();
const canCreate = canFleetAction(user, "trains", "create");
const canUpdate = canFleetAction(user, "trains", "update");
const resetPage = useCallback(() => { const resetPage = useCallback(() => {
setPagination((prev) => setPagination((prev) =>
@@ -240,24 +245,25 @@ export default function TrainBuilderListPage() {
id: "actions", id: "actions",
header: "", header: "",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => ( cell: ({ row }) =>
<ActionIcon canUpdate ? (
variant="subtle" <ActionIcon
color="gray" variant="subtle"
aria-label={`Edit train ${row.original.code}`} color="gray"
title="Edit name & train numbers" aria-label={`Edit train ${row.original.code}`}
onClick={(e) => { title="Edit name & train numbers"
// Row click navigates to the detail page — keep the edit local. onClick={(e) => {
e.stopPropagation(); // Row click navigates to the detail page — keep the edit local.
setEditTarget(row.original); e.stopPropagation();
}} setEditTarget(row.original);
> }}
<Pencil size={15} /> >
</ActionIcon> <Pencil size={15} />
), </ActionIcon>
) : null,
}, },
]; ];
}, []); }, [canUpdate]);
const tableStatus = trainsQuery.isLoading const tableStatus = trainsQuery.isLoading
? "loading" ? "loading"
@@ -271,9 +277,11 @@ export default function TrainBuilderListPage() {
title="Train Builder" title="Train Builder"
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit." subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
action={ action={
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}> canCreate ? (
Build train <Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
</Button> Build train
</Button>
) : undefined
} }
/> />