diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 84e3e4b7a..c6fe0fa7d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -108,6 +108,7 @@ import { ExportsModule } from "./modules/exports/exports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.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 { MaintenanceModule } from "./modules/maintenance/maintenance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module"; @@ -250,6 +251,7 @@ if (!process.env.APPLICATION_NAME) { UserTradeAccessModule, VehiclesModule, DriversModule, + TrainCrewModule, FuelModule, MaintenanceModule, ComplianceModule, diff --git a/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts b/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts new file mode 100644 index 000000000..95456bbd0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts new file mode 100644 index 000000000..6389baba7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts new file mode 100644 index 000000000..02579283a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts @@ -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'; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts new file mode 100644 index 000000000..18215e2b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto'; + +export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {} diff --git a/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts new file mode 100644 index 000000000..588678279 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts new file mode 100644 index 000000000..b5ba3dc27 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts new file mode 100644 index 000000000..e242204c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts new file mode 100644 index 000000000..ae8ffbafe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts @@ -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, + ) {} + + async create(dto: CreateTrainCrewMemberDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0a3d26d71..0292fd7c5 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1954,6 +1981,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, ...EMPTY_RETURN_REQUEST_PERMISSIONS, + ...TRAIN_CREW_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2331,6 +2359,12 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:drivers:update", 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: { view: "edr_freight_app:tracking:view", manage: "edr_freight_app:tracking:manage", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 652a331c7..0ce530d00 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; +import TrainCrewPage from "./pages/train-crew/TrainCrewPage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; @@ -1016,6 +1017,14 @@ const App = () => { } /> + + + + } + /> r.category === "rules ); 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", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 861b73014..16a055831 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -523,6 +523,18 @@ export const buildSidebarSections = ( }, ], }, + { + title: "Rolling stock", + mutedTitle: true, + items: [ + { + label: "Train Crew", + href: "/dashboard/train-crew", + icon: , + permission: FREIGHT_PERMS.trainCrew.view, + }, + ], + }, { title: "Freight configuration", mutedTitle: true, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 0ee03a626..c44e8c1f9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -194,6 +194,13 @@ export const QUERY_KEYS = { byId: (id: string) => ["vehicles", "detail", id] as const, }, + TRAIN_CREW: { + ROOT: ["train-crew"] as const, + list: (filter?: Record) => + ["train-crew", "list", filter ?? {}] as const, + byId: (id: string) => ["train-crew", "detail", id] as const, + }, + FIRST_MILE: { ROOT: ["first-mile"] as const, list: (filter?: Record) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ae972022e..d33fcf76c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -870,4 +870,9 @@ export const URL_CONSTANTS = { BASE: "/drivers", BY_ID: (id: string) => `/drivers/${id}`, }, + + TRAIN_CREW: { + BASE: "/train-crew", + BY_ID: (id: string) => `/train-crew/${id}`, + }, }; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bc03bfcb5..bcf6e021c 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -266,6 +266,12 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:drivers:update", 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: { view: "edr_freight_app:tracking:view", manage: "edr_freight_app:tracking:manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/train-crew/TrainCrewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/train-crew/TrainCrewPage.tsx new file mode 100644 index 000000000..fca9c6d1b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/train-crew/TrainCrewPage.tsx @@ -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 = { + 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(ALL); + const [nationalityFilter, setNationalityFilter] = useState(ALL); + const [statusFilter, setStatusFilter] = useState(ALL); + + const [modalOpen, setModalOpen] = useState(false); + /** Row being edited; null means the modal is in create mode. */ + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [deleteTarget, setDeleteTarget] = useState(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 = { + 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 ( + + + + +
+ Train Crew + + Roster of on-board personnel assignable to a train + +
+ {canCreate ? ( + + ) : null} +
+ + + + { + setSearch(e.currentTarget.value); + resetToFirstPage(); + }} + style={{ flex: 1, minWidth: 220 }} + /> + + setForm({ ...form, role: (val as TrainCrewRole) ?? "" })} + required + /> + + setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" }) + } + required + /> + setForm({ ...form, isActive: e.currentTarget.checked })} + /> + + + + + + + + + setDeleteTarget(null)} + title="Remove Crew Member" + size="md" + > + + + Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew + roster? + + + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/trainCrew.service.ts b/apps/edr-freight-web/backoffice/src/services/trainCrew.service.ts new file mode 100644 index 000000000..81cdbca4d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/trainCrew.service.ts @@ -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( + `${URL_CONSTANTS.TRAIN_CREW.BASE}${qs ? `?${qs}` : ''}`, + ); + }, + getById: (id: string) => + apiClient.get(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)), + create: (data: Partial) => + apiClient.post(URL_CONSTANTS.TRAIN_CREW.BASE, data), + update: (id: string, data: Partial) => + 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;