feat(train-crew): implement train crew management module

- Added DTOs for creating, querying, and updating train crew members.
- Created entity for train crew members with relevant fields and enums for role, nationality, and status.
- Developed service for handling CRUD operations and ensuring no duplicate crew members.
- Implemented controller to manage API endpoints for train crew operations.
- Integrated permissions for viewing, creating, updating, and deleting train crew members.
- Added frontend components for displaying, adding, editing, and deleting train crew members.
- Established API service for interacting with the train crew backend.
- Updated routing and sidebar to include train crew management section.
This commit is contained in:
marshalyordanos
2026-09-04 20:45:41 +03:00
parent ca84aac456
commit 8437a6d5f1
18 changed files with 1086 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
export class CreateTrainCrewMemberDto {
@IsString()
@MinLength(1)
@MaxLength(100)
firstName!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
lastName!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
@IsEnum(TrainCrewNationality)
nationality!: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */
export const TRAIN_CREW_SORT_FIELDS = [
'firstName',
'lastName',
'role',
'nationality',
'status',
'createdAt',
'updatedAt',
] as const;
export class QueryTrainCrewMemberDto {
/** Matched against first and last name. */
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(TrainCrewRole)
role?: TrainCrewRole;
@IsOptional()
@IsEnum(TrainCrewNationality)
nationality?: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@IsOptional()
@IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[])
sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number];
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}

View File

@@ -0,0 +1,69 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* On-board role a crew member is rostered for. Mirrors the crew composition
* rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security
* detail, technical maintenance, and the four specialized cargo roles.
*/
export enum TrainCrewRole {
TRAIN_DRIVER = 'TRAIN_DRIVER',
FEDERAL_POLICE = 'FEDERAL_POLICE',
TECHNICIAN = 'TECHNICIAN',
REEFER_TECHNICIAN = 'REEFER_TECHNICIAN',
HAZMAT_ESCORT = 'HAZMAT_ESCORT',
LASHING_INSPECTOR = 'LASHING_INSPECTOR',
LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER',
}
/**
* Employing country. Drives the territorial boundary in §1.1 — Djibouti train
* drivers operate only on the Dire Dawa Nagad segment — and the crewing
* cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers).
*/
export enum TrainCrewNationality {
ETHIOPIAN = 'ETHIOPIAN',
DJIBOUTIAN = 'DJIBOUTIAN',
}
export enum TrainCrewStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
ON_LEAVE = 'ON_LEAVE',
}
/**
* Roster of people assignable to a train. Distinct from `freight.drivers`,
* which is the road/last-mile truck driver register (licences, vehicle types,
* trip counts) — a train driver shares none of those fields.
*/
@Entity({ schema: 'freight', name: 'train_crew_members' })
@Index(['role'])
@Index(['nationality'])
@Index(['status'])
@Index(['isActive'])
export class TrainCrewMember extends BaseEntity {
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'nationality', type: 'varchar', length: 16 })
nationality!: TrainCrewNationality;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: TrainCrewStatus.ACTIVE,
})
status!: TrainCrewStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewService } from './train-crew.service';
@ApiTags('train-crew')
@ApiBearerAuth()
@Controller('train-crew')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.trainCrew.view,
FREIGHT_PERMS.trainCrew.create,
FREIGHT_PERMS.trainCrew.update,
FREIGHT_PERMS.trainCrew.delete,
])
export class TrainCrewController {
constructor(private readonly trainCrewService: TrainCrewService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.trainCrew.create)
@ApiOperation({ summary: 'Create a train crew member' })
create(@Body() dto: CreateTrainCrewMemberDto) {
return this.trainCrewService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List train crew members with filters' })
findAll(@Query() query: QueryTrainCrewMemberDto) {
return this.trainCrewService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get a train crew member by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.findById(id);
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.update)
@ApiOperation({ summary: 'Update a train crew member' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainCrewMemberDto,
) {
return this.trainCrewService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.delete)
@ApiOperation({ summary: 'Delete a train crew member' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.remove(id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainCrewMember } from './entities/train-crew-member.entity';
import { TrainCrewController } from './train-crew.controller';
import { TrainCrewService } from './train-crew.service';
@Module({
imports: [TypeOrmModule.forFeature([TrainCrewMember])],
providers: [TrainCrewService],
controllers: [TrainCrewController],
exports: [TrainCrewService],
})
export class TrainCrewModule {}

View File

@@ -0,0 +1,111 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewMember } from './entities/train-crew-member.entity';
const DEFAULT_LIMIT = 25;
@Injectable()
export class TrainCrewService {
constructor(
@InjectRepository(TrainCrewMember)
private readonly crewRepo: Repository<TrainCrewMember>,
) {}
async create(dto: CreateTrainCrewMemberDto): Promise<TrainCrewMember> {
await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role);
const member = this.crewRepo.create(dto);
return this.crewRepo.save(member);
}
async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}> {
const page = query.page ?? 1;
const limit = query.limit ?? DEFAULT_LIMIT;
const qb = this.crewRepo.createQueryBuilder('c');
if (query.search) {
qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', {
search: `%${query.search}%`,
});
}
if (query.role) qb.andWhere('c.role = :role', { role: query.role });
if (query.nationality) {
qb.andWhere('c.nationality = :nationality', { nationality: query.nationality });
}
if (query.status) qb.andWhere('c.status = :status', { status: query.status });
if (query.isActive !== undefined) {
qb.andWhere('c.isActive = :isActive', { isActive: query.isActive });
}
// sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here.
const [data, total] = await qb
.orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<TrainCrewMember> {
const member = await this.crewRepo.findOne({ where: { id } });
if (!member) {
throw new NotFoundException(`Train crew member ${id} not found`);
}
return member;
}
async update(id: string, dto: UpdateTrainCrewMemberDto): Promise<TrainCrewMember> {
const member = await this.findById(id);
const firstName = dto.firstName ?? member.firstName;
const lastName = dto.lastName ?? member.lastName;
const role = dto.role ?? member.role;
const identityChanged =
firstName !== member.firstName ||
lastName !== member.lastName ||
role !== member.role;
if (identityChanged) {
await this.assertNoDuplicate(firstName, lastName, role, id);
}
Object.assign(member, dto);
return this.crewRepo.save(member);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.crewRepo.softDelete(id);
}
/**
* The roster carries no employee number yet, so name + role is the only
* identity available to catch an accidental re-entry of the same person.
* Case-insensitive; `exceptId` skips the row being updated.
*/
private async assertNoDuplicate(
firstName: string,
lastName: string,
role: string,
exceptId?: string,
): Promise<void> {
const existing = await this.crewRepo.findOne({
where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never },
});
if (existing && existing.id !== exceptId) {
throw new ConflictException(
`Train crew member ${firstName} ${lastName} (${role}) already exists`,
);
}
}
}