mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 02:28:18 +00:00
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:
@@ -108,6 +108,7 @@ import { ExportsModule } from "./modules/exports/exports.module";
|
|||||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
|
import { TrainCrewModule } from "./modules/train-crew/train-crew.module";
|
||||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||||
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
||||||
@@ -250,6 +251,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
TrainCrewModule,
|
||||||
FuelModule,
|
FuelModule,
|
||||||
MaintenanceModule,
|
MaintenanceModule,
|
||||||
ComplianceModule,
|
ComplianceModule,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
|
||||||
|
* composition). Separate from freight.drivers, which registers road/last-mile
|
||||||
|
* truck drivers and shares none of these fields.
|
||||||
|
*
|
||||||
|
* Role and nationality are stored as varchar rather than PG enums so adding a
|
||||||
|
* crew role later is an application change, not a type migration. The partial
|
||||||
|
* unique index keys on name + role — the roster has no employee number yet, so
|
||||||
|
* that is the only identity available to block an accidental re-entry; it is
|
||||||
|
* scoped to live rows so a soft-deleted member does not hold the name hostage.
|
||||||
|
*/
|
||||||
|
export class TrainCrewMembers3850000000000 implements MigrationInterface {
|
||||||
|
name = 'TrainCrewMembers3850000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_name varchar(100) NOT NULL,
|
||||||
|
last_name varchar(100) NOT NULL,
|
||||||
|
role varchar(32) NOT NULL,
|
||||||
|
nationality varchar(16) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT chk_train_crew_role CHECK (role IN (
|
||||||
|
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
|
||||||
|
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
|
||||||
|
'ETHIOPIAN','DJIBOUTIAN'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_train_crew_status CHECK (status IN (
|
||||||
|
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
|
||||||
|
ON freight.train_crew_members (role)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
|
||||||
|
ON freight.train_crew_members (nationality)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
|
||||||
|
ON freight.train_crew_members (status)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
|
||||||
|
ON freight.train_crew_members (is_active)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
|
||||||
|
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
|
||||||
|
|
||||||
|
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1506,6 +1506,33 @@ export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// E'''. Train crew roster — the people assignable to a train (drivers, federal
|
||||||
|
// police, technicians, specialized cargo crew) per ITLMS Rolling Stock 1.2.
|
||||||
|
// Distinct from the `drivers` keys above, which gate the road/last-mile truck
|
||||||
|
// driver register.
|
||||||
|
export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm(
|
||||||
|
"f5a00001-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:train_crew:view",
|
||||||
|
"View train crew members",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"f5a00001-0001-4000-8000-000000000002",
|
||||||
|
"edr_freight_app:train_crew:create",
|
||||||
|
"Create train crew member",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"f5a00001-0001-4000-8000-000000000003",
|
||||||
|
"edr_freight_app:train_crew:update",
|
||||||
|
"Update train crew member",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"f5a00001-0001-4000-8000-000000000004",
|
||||||
|
"edr_freight_app:train_crew:delete",
|
||||||
|
"Delete train crew member",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// E'. Train-scheduling finer actions (augment existing view/manage)
|
// E'. Train-scheduling finer actions (augment existing view/manage)
|
||||||
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
perm(
|
perm(
|
||||||
@@ -1954,6 +1981,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
...PORT_TERMINAL_PERMISSIONS,
|
...PORT_TERMINAL_PERMISSIONS,
|
||||||
...ADDITIONAL_CHARGE_PERMISSIONS,
|
...ADDITIONAL_CHARGE_PERMISSIONS,
|
||||||
...EMPTY_RETURN_REQUEST_PERMISSIONS,
|
...EMPTY_RETURN_REQUEST_PERMISSIONS,
|
||||||
|
...TRAIN_CREW_PERMISSIONS,
|
||||||
...SCHEDULING_EXTRA_PERMISSIONS,
|
...SCHEDULING_EXTRA_PERMISSIONS,
|
||||||
...CONFIG_SETTINGS_PERMISSIONS,
|
...CONFIG_SETTINGS_PERMISSIONS,
|
||||||
...STAFF_IAM_PERMISSIONS,
|
...STAFF_IAM_PERMISSIONS,
|
||||||
@@ -2331,6 +2359,12 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:drivers:update",
|
update: "edr_freight_app:drivers:update",
|
||||||
delete: "edr_freight_app:drivers:delete",
|
delete: "edr_freight_app:drivers:delete",
|
||||||
},
|
},
|
||||||
|
trainCrew: {
|
||||||
|
view: "edr_freight_app:train_crew:view",
|
||||||
|
create: "edr_freight_app:train_crew:create",
|
||||||
|
update: "edr_freight_app:train_crew:update",
|
||||||
|
delete: "edr_freight_app:train_crew:delete",
|
||||||
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
view: "edr_freight_app:tracking:view",
|
view: "edr_freight_app:tracking:view",
|
||||||
manage: "edr_freight_app:tracking:manage",
|
manage: "edr_freight_app:tracking:manage",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman
|
|||||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||||
|
import TrainCrewPage from "./pages/train-crew/TrainCrewPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
|
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
|
||||||
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
|
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
|
||||||
@@ -1016,6 +1017,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="train-crew"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainCrew.view}>
|
||||||
|
<TrainCrewPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="fuel-purchases"
|
path="fuel-purchases"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/train-crew",
|
||||||
|
meta: {
|
||||||
|
title: "Train Crew",
|
||||||
|
subtitle: "Roster of on-board personnel assignable to a train",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/overview",
|
prefix: "/dashboard/overview",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -523,6 +523,18 @@ export const buildSidebarSections = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Rolling stock",
|
||||||
|
mutedTitle: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Train Crew",
|
||||||
|
href: "/dashboard/train-crew",
|
||||||
|
icon: <Users />,
|
||||||
|
permission: FREIGHT_PERMS.trainCrew.view,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Freight configuration",
|
title: "Freight configuration",
|
||||||
mutedTitle: true,
|
mutedTitle: true,
|
||||||
|
|||||||
@@ -194,6 +194,13 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["vehicles", "detail", id] as const,
|
byId: (id: string) => ["vehicles", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
TRAIN_CREW: {
|
||||||
|
ROOT: ["train-crew"] as const,
|
||||||
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
["train-crew", "list", filter ?? {}] as const,
|
||||||
|
byId: (id: string) => ["train-crew", "detail", id] as const,
|
||||||
|
},
|
||||||
|
|
||||||
FIRST_MILE: {
|
FIRST_MILE: {
|
||||||
ROOT: ["first-mile"] as const,
|
ROOT: ["first-mile"] as const,
|
||||||
list: (filter?: Record<string, unknown>) =>
|
list: (filter?: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -870,4 +870,9 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/drivers",
|
BASE: "/drivers",
|
||||||
BY_ID: (id: string) => `/drivers/${id}`,
|
BY_ID: (id: string) => `/drivers/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
TRAIN_CREW: {
|
||||||
|
BASE: "/train-crew",
|
||||||
|
BY_ID: (id: string) => `/train-crew/${id}`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,6 +266,12 @@ export const FREIGHT_PERMS = {
|
|||||||
update: "edr_freight_app:drivers:update",
|
update: "edr_freight_app:drivers:update",
|
||||||
delete: "edr_freight_app:drivers:delete",
|
delete: "edr_freight_app:drivers:delete",
|
||||||
},
|
},
|
||||||
|
trainCrew: {
|
||||||
|
view: "edr_freight_app:train_crew:view",
|
||||||
|
create: "edr_freight_app:train_crew:create",
|
||||||
|
update: "edr_freight_app:train_crew:update",
|
||||||
|
delete: "edr_freight_app:train_crew:delete",
|
||||||
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
view: "edr_freight_app:tracking:view",
|
view: "edr_freight_app:tracking:view",
|
||||||
manage: "edr_freight_app:tracking:manage",
|
manage: "edr_freight_app:tracking:manage",
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
// Generic list footer — shared by the fleet and train-scheduling lists despite
|
||||||
|
// the ruleEngine path.
|
||||||
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import {
|
||||||
|
TRAIN_CREW_NATIONALITY_OPTIONS,
|
||||||
|
TRAIN_CREW_ROLE_OPTIONS,
|
||||||
|
TRAIN_CREW_STATUS_OPTIONS,
|
||||||
|
trainCrewNationalityLabel,
|
||||||
|
trainCrewRoleLabel,
|
||||||
|
trainCrewService,
|
||||||
|
trainCrewStatusLabel,
|
||||||
|
type SaveTrainCrewMemberPayload,
|
||||||
|
type TrainCrewMember,
|
||||||
|
type TrainCrewNationality,
|
||||||
|
type TrainCrewRole,
|
||||||
|
type TrainCrewStatus,
|
||||||
|
} from "@/services/trainCrew.service";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
const ALL = "__all__";
|
||||||
|
|
||||||
|
/** Mantine colour per status, so the roster reads at a glance. */
|
||||||
|
const STATUS_COLOR: Record<TrainCrewStatus, string> = {
|
||||||
|
ACTIVE: "green",
|
||||||
|
INACTIVE: "gray",
|
||||||
|
SUSPENDED: "red",
|
||||||
|
ON_LEAVE: "yellow",
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: TrainCrewRole | "";
|
||||||
|
nationality: TrainCrewNationality | "";
|
||||||
|
status: TrainCrewStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
role: "",
|
||||||
|
nationality: "",
|
||||||
|
status: "ACTIVE",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrainCrewPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const canCreate = hasPermission(user, FREIGHT_PERMS.trainCrew.create);
|
||||||
|
const canUpdate = hasPermission(user, FREIGHT_PERMS.trainCrew.update);
|
||||||
|
const canDelete = hasPermission(user, FREIGHT_PERMS.trainCrew.delete);
|
||||||
|
|
||||||
|
// The footer owns page size as well as page, so both live here. `pageIndex`
|
||||||
|
// is 0-based to match the footer's PaginationState; the API is 1-based.
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string>(ALL);
|
||||||
|
const [nationalityFilter, setNationalityFilter] = useState<string>(ALL);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>(ALL);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
/** Row being edited; null means the modal is in create mode. */
|
||||||
|
const [editing, setEditing] = useState<TrainCrewMember | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<TrainCrewMember | null>(null);
|
||||||
|
|
||||||
|
// Filtering and paging are server-side, so the active filters are part of the
|
||||||
|
// query key — changing one refetches rather than slicing a stale page.
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
...(search.trim() ? { search: search.trim() } : {}),
|
||||||
|
...(roleFilter !== ALL ? { role: roleFilter as TrainCrewRole } : {}),
|
||||||
|
...(nationalityFilter !== ALL
|
||||||
|
? { nationality: nationalityFilter as TrainCrewNationality }
|
||||||
|
: {}),
|
||||||
|
...(statusFilter !== ALL ? { status: statusFilter as TrainCrewStatus } : {}),
|
||||||
|
}),
|
||||||
|
[pagination, search, roleFilter, nationalityFilter, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_CREW.list(filters),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await trainCrewService.getAll(filters);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = data?.data ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_CREW.ROOT });
|
||||||
|
|
||||||
|
const describeError = (error: unknown, fallback: string): string => {
|
||||||
|
const message = (error as { response?: { data?: { message?: unknown } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join(", ");
|
||||||
|
return typeof message === "string" ? message : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (values: FormState) => {
|
||||||
|
const payload: Partial<SaveTrainCrewMemberPayload> = {
|
||||||
|
firstName: values.firstName.trim(),
|
||||||
|
lastName: values.lastName.trim(),
|
||||||
|
role: values.role as TrainCrewRole,
|
||||||
|
nationality: values.nationality as TrainCrewNationality,
|
||||||
|
status: values.status,
|
||||||
|
isActive: values.isActive,
|
||||||
|
};
|
||||||
|
return editing
|
||||||
|
? trainCrewService.update(editing.id, payload)
|
||||||
|
: trainCrewService.create(payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: editing ? "Crew member updated" : "Crew member added" });
|
||||||
|
closeModal();
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast({
|
||||||
|
title: editing ? "Could not update crew member" : "Could not add crew member",
|
||||||
|
description: describeError(error, "The request failed. Please try again."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => trainCrewService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Crew member removed" });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast({
|
||||||
|
title: "Could not remove crew member",
|
||||||
|
description: describeError(error, "The request failed. Please try again."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (member: TrainCrewMember) => {
|
||||||
|
setEditing(member);
|
||||||
|
setForm({
|
||||||
|
firstName: member.firstName,
|
||||||
|
lastName: member.lastName,
|
||||||
|
role: member.role,
|
||||||
|
nationality: member.nationality,
|
||||||
|
status: member.status,
|
||||||
|
isActive: member.isActive,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(EMPTY_FORM);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every column is NOT NULL server-side, so all four must be filled. */
|
||||||
|
const formValid =
|
||||||
|
form.firstName.trim().length > 0 &&
|
||||||
|
form.lastName.trim().length > 0 &&
|
||||||
|
form.role !== "" &&
|
||||||
|
form.nationality !== "";
|
||||||
|
|
||||||
|
// Filters narrow the result set, so a page beyond the new last page would
|
||||||
|
// render empty — reset to the first page whenever one changes.
|
||||||
|
const resetToFirstPage = () =>
|
||||||
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||||
|
|
||||||
|
const onFilterChange = (setter: (value: string) => void) => (value: string | null) => {
|
||||||
|
setter(value ?? ALL);
|
||||||
|
resetToFirstPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="xl" px="lg">
|
||||||
|
<Breadcrumbs items={[{ label: "Train Crew" }, { label: "Crew Members" }]} />
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="lg">
|
||||||
|
<div>
|
||||||
|
<Title order={1}>Train Crew</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Roster of on-board personnel assignable to a train
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{canCreate ? (
|
||||||
|
<Button leftSection={<Plus size={16} />} onClick={openCreate} color="edr-green">
|
||||||
|
Add Crew Member
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Group p="md" gap="sm" align="flex-end" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
label="Search"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.currentTarget.value);
|
||||||
|
resetToFirstPage();
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
data={[{ label: "All roles", value: ALL }, ...TRAIN_CREW_ROLE_OPTIONS]}
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={onFilterChange(setRoleFilter)}
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Nationality"
|
||||||
|
data={[
|
||||||
|
{ label: "All nationalities", value: ALL },
|
||||||
|
...TRAIN_CREW_NATIONALITY_OPTIONS,
|
||||||
|
]}
|
||||||
|
value={nationalityFilter}
|
||||||
|
onChange={onFilterChange(setNationalityFilter)}
|
||||||
|
style={{ minWidth: 170 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={[{ label: "All statuses", value: ALL }, ...TRAIN_CREW_STATUS_OPTIONS]}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={onFilterChange(setStatusFilter)}
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>First Name</Table.Th>
|
||||||
|
<Table.Th>Last Name</Table.Th>
|
||||||
|
<Table.Th>Role</Table.Th>
|
||||||
|
<Table.Th>Nationality</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Active</Table.Th>
|
||||||
|
<Table.Th>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={7}>
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={7}>
|
||||||
|
<Text c="dimmed" ta="center" py="md">
|
||||||
|
No crew members found.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : null}
|
||||||
|
{members.map((member) => (
|
||||||
|
<Table.Tr key={member.id}>
|
||||||
|
<Table.Td>{member.firstName}</Table.Td>
|
||||||
|
<Table.Td>{member.lastName}</Table.Td>
|
||||||
|
<Table.Td>{trainCrewRoleLabel(member.role)}</Table.Td>
|
||||||
|
<Table.Td>{trainCrewNationalityLabel(member.nationality)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={STATUS_COLOR[member.status] ?? "gray"}>
|
||||||
|
{trainCrewStatusLabel(member.status)}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={member.isActive ? "green" : "gray"} variant="light">
|
||||||
|
{member.isActive ? "Yes" : "No"}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs">
|
||||||
|
{canUpdate ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Pencil size={14} />}
|
||||||
|
onClick={() => openEdit(member)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDelete ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<Trash2 size={14} />}
|
||||||
|
onClick={() => setDeleteTarget(member)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<RuleEngineListFooter
|
||||||
|
itemLabel="crew members"
|
||||||
|
pagination={pagination}
|
||||||
|
pageCount={Math.ceil(total / pagination.pageSize)}
|
||||||
|
totalCount={total}
|
||||||
|
onPaginationChange={setPagination}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title={editing ? "Edit Crew Member" : "Add Crew Member"}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="First Name"
|
||||||
|
placeholder="First name"
|
||||||
|
value={form.firstName}
|
||||||
|
onChange={(e) => setForm({ ...form, firstName: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Last Name"
|
||||||
|
placeholder="Last name"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={(e) => setForm({ ...form, lastName: e.currentTarget.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
placeholder="Select role"
|
||||||
|
data={TRAIN_CREW_ROLE_OPTIONS}
|
||||||
|
value={form.role || null}
|
||||||
|
onChange={(val) => setForm({ ...form, role: (val as TrainCrewRole) ?? "" })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Nationality"
|
||||||
|
placeholder="Select nationality"
|
||||||
|
data={TRAIN_CREW_NATIONALITY_OPTIONS}
|
||||||
|
value={form.nationality || null}
|
||||||
|
onChange={(val) =>
|
||||||
|
setForm({ ...form, nationality: (val as TrainCrewNationality) ?? "" })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
data={TRAIN_CREW_STATUS_OPTIONS}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(val) =>
|
||||||
|
setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label="Active"
|
||||||
|
checked={form.isActive}
|
||||||
|
onChange={(e) => setForm({ ...form, isActive: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={closeModal}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMutation.mutate(form)}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
disabled={!formValid}
|
||||||
|
>
|
||||||
|
{editing ? "Save Changes" : "Add Crew Member"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={deleteTarget !== null}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
title="Remove Crew Member"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew
|
||||||
|
roster?
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="light" onClick={() => setDeleteTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { api as apiClient } from '../auth/http';
|
||||||
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export type TrainCrewRole =
|
||||||
|
| 'TRAIN_DRIVER'
|
||||||
|
| 'FEDERAL_POLICE'
|
||||||
|
| 'TECHNICIAN'
|
||||||
|
| 'REEFER_TECHNICIAN'
|
||||||
|
| 'HAZMAT_ESCORT'
|
||||||
|
| 'LASHING_INSPECTOR'
|
||||||
|
| 'LIVESTOCK_HANDLER';
|
||||||
|
|
||||||
|
export type TrainCrewNationality = 'ETHIOPIAN' | 'DJIBOUTIAN';
|
||||||
|
|
||||||
|
export type TrainCrewStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'ON_LEAVE';
|
||||||
|
|
||||||
|
export interface TrainCrewMember {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: TrainCrewRole;
|
||||||
|
nationality: TrainCrewNationality;
|
||||||
|
status: TrainCrewStatus;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainCrewListFilters {
|
||||||
|
search?: string;
|
||||||
|
role?: TrainCrewRole;
|
||||||
|
nationality?: TrainCrewNationality;
|
||||||
|
status?: TrainCrewStatus;
|
||||||
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paginated envelope returned by GET /train-crew. */
|
||||||
|
export interface TrainCrewListResponse {
|
||||||
|
data: TrainCrewMember[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveTrainCrewMemberPayload = Omit<
|
||||||
|
TrainCrewMember,
|
||||||
|
'id' | 'createdAt' | 'updatedAt'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const trainCrewService = {
|
||||||
|
getAll: (filters: TrainCrewListFilters = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.set('search', filters.search);
|
||||||
|
if (filters.role) params.set('role', filters.role);
|
||||||
|
if (filters.nationality) params.set('nationality', filters.nationality);
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.isActive !== undefined) {
|
||||||
|
params.set('isActive', String(filters.isActive));
|
||||||
|
}
|
||||||
|
if (filters.page) params.set('page', String(filters.page));
|
||||||
|
if (filters.limit) params.set('limit', String(filters.limit));
|
||||||
|
if (filters.sortBy) params.set('sortBy', filters.sortBy);
|
||||||
|
if (filters.sortOrder) params.set('sortOrder', filters.sortOrder);
|
||||||
|
const qs = params.toString();
|
||||||
|
return apiClient.get<TrainCrewListResponse>(
|
||||||
|
`${URL_CONSTANTS.TRAIN_CREW.BASE}${qs ? `?${qs}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getById: (id: string) =>
|
||||||
|
apiClient.get<TrainCrewMember>(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
|
||||||
|
create: (data: Partial<SaveTrainCrewMemberPayload>) =>
|
||||||
|
apiClient.post(URL_CONSTANTS.TRAIN_CREW.BASE, data),
|
||||||
|
update: (id: string, data: Partial<SaveTrainCrewMemberPayload>) =>
|
||||||
|
apiClient.patch(URL_CONSTANTS.TRAIN_CREW.BY_ID(id), data),
|
||||||
|
delete: (id: string) => apiClient.delete(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRAIN_CREW_ROLE_OPTIONS: Array<{ label: string; value: TrainCrewRole }> = [
|
||||||
|
{ label: 'Train Driver', value: 'TRAIN_DRIVER' },
|
||||||
|
{ label: 'Federal Police', value: 'FEDERAL_POLICE' },
|
||||||
|
{ label: 'Technician', value: 'TECHNICIAN' },
|
||||||
|
{ label: 'Reefer Technician', value: 'REEFER_TECHNICIAN' },
|
||||||
|
{ label: 'HAZMAT Escort', value: 'HAZMAT_ESCORT' },
|
||||||
|
{ label: 'Lashing Inspector', value: 'LASHING_INSPECTOR' },
|
||||||
|
{ label: 'Livestock Handler', value: 'LIVESTOCK_HANDLER' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRAIN_CREW_NATIONALITY_OPTIONS: Array<{
|
||||||
|
label: string;
|
||||||
|
value: TrainCrewNationality;
|
||||||
|
}> = [
|
||||||
|
{ label: 'Ethiopian', value: 'ETHIOPIAN' },
|
||||||
|
{ label: 'Djiboutian', value: 'DJIBOUTIAN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRAIN_CREW_STATUS_OPTIONS: Array<{ label: string; value: TrainCrewStatus }> = [
|
||||||
|
{ label: 'Active', value: 'ACTIVE' },
|
||||||
|
{ label: 'Inactive', value: 'INACTIVE' },
|
||||||
|
{ label: 'Suspended', value: 'SUSPENDED' },
|
||||||
|
{ label: 'On leave', value: 'ON_LEAVE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const trainCrewRoleLabel = (role: TrainCrewRole): string =>
|
||||||
|
TRAIN_CREW_ROLE_OPTIONS.find((o) => o.value === role)?.label ?? role;
|
||||||
|
|
||||||
|
export const trainCrewNationalityLabel = (n: TrainCrewNationality): string =>
|
||||||
|
TRAIN_CREW_NATIONALITY_OPTIONS.find((o) => o.value === n)?.label ?? n;
|
||||||
|
|
||||||
|
export const trainCrewStatusLabel = (s: TrainCrewStatus): string =>
|
||||||
|
TRAIN_CREW_STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s;
|
||||||
Reference in New Issue
Block a user