feat(train-crew): implement crew assignment functionality

- Added TrainCrewAssignment module with controller and service for managing crew assignments.
- Integrated TrainCrewAssignmentService into TrainSchedulingService to ensure crew readiness before train dispatch.
- Updated TrainScheduling module to include TrainCrewModule for dependency injection.
- Introduced new permissions for assigning train crew in freight permissions registry.
- Enhanced front-end ScheduleCrewPage to allow assignment of crew members to train schedules, including validation and UI for adding/removing drivers and support crew.
- Created trainCrewAssignment.service to handle API interactions for crew assignments.
This commit is contained in:
marshalyordanos
2026-09-06 10:29:22 +03:00
parent 63ccb060c6
commit c5c8712c5b
16 changed files with 2324 additions and 8 deletions

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
*
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
* splits four drivers across the Dire Dawa boundary, and a driver who is
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
* roster edit cannot rewrite the crew of a run that already departed.
*
* The partial unique index on (schedule, segment, duty_role) applies to drivers
* only — one segment cannot have two Primaries, while four federal police on
* the same run carry no segment or duty role and are unconstrained by it.
*/
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
name = 'TrainCrewAssignments3860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id uuid NOT NULL,
crew_member_id uuid NOT NULL
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
role varchar(32) NOT NULL,
duty_role varchar(16),
segment varchar(24),
crewing_case varchar(8),
layover_start_at timestamptz,
layover_end_at timestamptz,
duty_start_at timestamptz,
duty_end_at timestamptz,
status varchar(16) NOT NULL DEFAULT 'PLANNED',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_crew_assignment_duty_role CHECK (
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
),
CONSTRAINT chk_crew_assignment_segment CHECK (
segment IS NULL OR segment IN
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
),
CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
),
CONSTRAINT chk_crew_assignment_status CHECK (
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
ON freight.train_crew_assignments (train_schedule_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
ON freight.train_crew_assignments (crew_member_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
ON freight.train_crew_assignments (status)
`);
// Nobody holds two seats on one run.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
WHERE deleted_at IS NULL
`);
// One Primary (and one Assistant) per segment — drivers only.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
// Monthly overtime rollups scan a member's duty spans (§3.1).
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
WHERE duty_start_at IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drop `crewing_case` from train crew assignments.
*
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
* its own need instead — any number of drivers, each carrying their own segment
* and duty role — so the case has nothing left to select and the column no
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
* per-driver rule and still enforced.
*/
export class DropCrewingCase3870000000000 implements MigrationInterface {
name = 'DropCrewingCase3870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS crewing_case
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
)
`);
}
}

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Driver legs become yard-to-yard instead of three fixed corridor segments.
*
* The old `segment` enum could only express IndodeDire Dawa, Dire DawaNagad
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
* places), so a leg is now any two yards on the schedule's route.
*
* `segment` is kept, nullable, so rows written before this still read back; it
* is never populated again. The §1.1 territorial boundary is unaffected — it
* now derives from yard position rather than the segment name, so a Djibouti
* driver is still confined to Dire Dawa and eastward.
*
* The driver-slot unique index moves with it: one Primary per leg, where a leg
* is the (from, to) pair rather than a segment name.
*/
export class CrewLegYards3880000000000 implements MigrationInterface {
name = 'CrewLegYards3880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
`);
// Backfill the three legacy segments onto real yards so historic rows keep
// a usable leg. Matched by code; a deployment missing one simply leaves
// those rows with a null leg, which the validator reports as incomplete.
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'INDODE_DIRE_DAWA'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'DIRE_DAWA_NAGAD'
AND a.from_yard_id IS NULL
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'FULL_CORRIDOR'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'NAGAD'
`);
// One Primary per leg replaces one Primary per segment.
await queryRunner.query(`
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
ON freight.train_crew_assignments
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
WHERE deleted_at IS NULL
AND from_yard_id IS NOT NULL
AND to_yard_id IS NOT NULL
AND duty_role IS NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
WHERE from_yard_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS from_yard_id,
DROP COLUMN IF EXISTS to_yard_id
`);
}
}

View File

@@ -0,0 +1,346 @@
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
legAllowsNationality,
overtimeHours,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
/**
* A slice of the real corridor, using the production display_order values:
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
*/
const YARD: Record<string, CorridorYard> = {
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
};
const CORRIDOR: CorridorContext = {
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
originOrder: YARD.GMP.displayOrder,
destinationOrder: YARD.NAGAD.displayOrder,
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
};
const NO_DEMAND: CrewDemandInput = {
hasBadOrderWagon: false,
badOrderWagonLabels: [],
hasReeferCargo: false,
reeferSources: [],
hasHazmatCargo: false,
hazmatSources: [],
hasBreakBulkCargo: false,
breakBulkSources: [],
hasLivestockCargo: false,
livestockSources: [],
};
let seq = 0;
const driver = (
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
from: CorridorYard,
to: CorridorYard,
dutyRole: CrewDutyRole,
): AssignmentFacts => ({
crewMemberId: `driver-${++seq}`,
role: TrainCrewRole.TRAIN_DRIVER,
dutyRole,
fromYardId: from.id,
toYardId: to.id,
nationality,
memberName: `Driver ${seq}`,
});
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
Array.from({ length: count }, () => ({
crewMemberId: `member-${++seq}`,
role,
nationality: 'ETHIOPIAN',
memberName: `Member ${seq}`,
}));
const codes = (result: { issues: Array<{ code: string }> }) =>
result.issues.map((i) => i.code);
describe('crew composition rules (ITLMS Rolling Stock)', () => {
const validate = (
assignments: AssignmentFacts[],
demand: CrewDemandInput = NO_DEMAND,
) => validateCrewComposition(assignments, demand, CORRIDOR);
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
const soloPrimary = () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
describe('free-form crew sizing', () => {
it('accepts a single driver working the whole corridor', () => {
const result = validate([soloPrimary()]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
const drivers = [
soloPrimary(),
...Array.from({ length: count - 1 }, () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
),
];
expect(validate(drivers).complete).toBe(true);
});
it('accepts any number of federal police, including none', () => {
for (const count of [0, 1, 4, 9]) {
const result = validate([
soloPrimary(),
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
]);
expect(result.complete).toBe(true);
}
});
it('requires at least one driver', () => {
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
expect(codes(result)).toContain('DRIVER_COUNT');
});
});
describe('yard-to-yard legs', () => {
it('lets staff hand over at any intermediate yard', () => {
// Three legs the old fixed segments could not express: GMPFeto,
// FetoMeiso, MeisoNagad.
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it('rejects a leg with the same yard at both ends', () => {
const result = validate([
driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DRIVER_LEG_EMPTY');
});
it('rejects a yard outside the schedule route', () => {
const outside: CorridorYard = {
id: 'y-sebeta',
label: 'Sebeta',
country: 'Ethiopia',
displayOrder: 1, // before the GMP origin
};
const corridor: CorridorContext = {
...CORRIDOR,
yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]),
};
const result = validateCrewComposition(
[driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)],
NO_DEMAND,
corridor,
);
expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE');
});
it('requires a from-yard, to-yard and duty role on every driver', () => {
const result = validate([
{
crewMemberId: 'd1',
role: TrainCrewRole.TRAIN_DRIVER,
nationality: 'ETHIOPIAN',
memberName: 'Unslotted Driver',
},
]);
expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE');
});
});
describe('§1.1 territorial boundary', () => {
const dd = YARD.DIRE_DAWA.displayOrder;
it('lets a Djibouti driver work at or beyond Dire Dawa', () => {
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true);
});
it('bars a Djibouti driver from any leg west of Dire Dawa', () => {
expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false);
expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false);
});
it('leaves Ethiopian drivers unrestricted', () => {
expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
});
it('flags a Djibouti driver placed on a western leg', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('TERRITORIAL_BOUNDARY');
});
it('accepts the documented split: Ethiopians west, Djiboutians east', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(result.issues).toEqual([]);
expect(result.runType).toBe('LONG_RUN');
});
});
describe('one Primary per leg', () => {
it('rejects two Primaries on the same leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DUPLICATE_PRIMARY');
});
it('allows a Primary on each of two different legs', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.complete).toBe(true);
});
it('allows many Assistants alongside one Primary', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF),
]);
expect(result.complete).toBe(true);
});
it('requires a Primary on every covered leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(codes(result)).toContain('PRIMARY_MISSING');
});
});
describe('§1.1 run type', () => {
it('is a long run when the legs span the whole route', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('LONG_RUN');
});
it('is a short run when the legs cover only part of the route', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('SHORT_RUN');
});
});
describe('§1.2 technical maintenance crew', () => {
it('requires no technician when no bad-order wagon is attached', () => {
expect(technicianRequirement(NO_DEMAND).min).toBe(0);
});
it('forces one technician when a bad-order wagon is attached', () => {
const demand = {
...NO_DEMAND,
hasBadOrderWagon: true,
badOrderWagonLabels: ['WG-1042'],
};
expect(technicianRequirement(demand).min).toBe(1);
const result = validate([soloPrimary()], demand);
expect(codes(result)).toContain('TECHNICIAN_REQUIRED');
// The wagon that forced it is named, so the demand is explicable.
expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message)
.toContain('WG-1042');
});
});
describe('§1.2 specialized cargo crew', () => {
it('asks for nothing when no specialized cargo is aboard', () => {
expect(specializedRequirements(NO_DEMAND)).toEqual([]);
});
it('requires a reefer technician only when reefer cargo is aboard', () => {
const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] };
const rules = specializedRequirements(demand);
expect(rules).toHaveLength(1);
expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN);
expect(rules[0].min).toBe(1);
});
it('blocks a hazmat run with no escort assigned', () => {
const result = validate([soloPrimary()], {
...NO_DEMAND,
hasHazmatCargo: true,
hazmatSources: ['BK-2024-0891'],
});
expect(codes(result)).toContain('SPECIALIZED_REQUIRED');
expect(result.complete).toBe(false);
});
it('passes once the escort is assigned, at any count', () => {
for (const escorts of [1, 2, 5]) {
const result = validate(
[soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)],
{ ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] },
);
expect(result.complete).toBe(true);
}
});
});
describe('duplicate seats', () => {
it('flags a member assigned twice on one run', () => {
const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0];
const result = validate([soloPrimary(), twice, twice]);
expect(codes(result)).toContain('DUPLICATE_MEMBER');
});
});
describe('§3.2 overtime hours', () => {
it('reproduces the documented worked example', () => {
// PDF: 500h worked against a 240h standard => 260h variance,
// split 156h at the 1.5x tier and 104h at the 1.75x tier.
expect(overtimeHours(500)).toEqual({
variance: 260,
tier1Hours: 156,
tier2Hours: 104,
});
});
it('reports no overtime below the monthly standard', () => {
expect(overtimeHours(200)).toEqual({
variance: 0,
tier1Hours: 0,
tier2Hours: 0,
});
});
it('splits the variance 60/40 as a flat convention', () => {
const { tier1Hours, tier2Hours, variance } = overtimeHours(340);
expect(variance).toBe(100);
expect(tier1Hours).toBe(60);
expect(tier2Hours).toBe(40);
});
});
});

View File

@@ -0,0 +1,437 @@
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
CrewDutyRole,
CrewSegment,
} from './entities/train-crew-assignment.entity';
/**
* ITLMS Rolling Stock §1.2 / §2 composition rules.
*
* One module, used by BOTH the assignment API and the dispatch guard, so the
* wizard and the departure gate can never disagree about whether a crew is
* complete. Pure functions over plain data — no repository access — so the
* caller decides what to load and this stays unit-testable.
*/
/**
* Security-detail size (§1.2 names 4 federal police).
*
* Operations asked for free-form crewing, so the document's numbers are treated
* as the usual shape rather than a hard limit — any count is accepted and the
* typical value is surfaced as a hint in the UI.
*/
export const FEDERAL_POLICE_TYPICAL = 4;
/** Government monthly working-hour baseline (§3.1). */
export const MONTHLY_STANDARD_HOURS = 240;
/**
* §3.2 tier split. The document fixes the day/night division as a flat 60/40 of
* the variance regardless of when the hours fell, and that is implemented as
* written rather than derived from real clock hours.
*/
export const OT_TIER_1_SHARE = 0.6;
export const OT_TIER_2_SHARE = 0.4;
export const OT_TIER_1_FACTOR = 1.5;
export const OT_TIER_2_FACTOR = 1.75;
/**
* Driving-crew size (§1.2 "3 or 4 Drivers").
*
* Operations asked for a free-form crew rather than the two fixed pairing cases
* of §2, so the document's 3-or-4 is treated as the usual shape, not a limit:
* any count within these bounds is accepted and each driver carries their own
* segment and duty role. MIN stays at 1 so a partially built crew still saves.
*/
export const DRIVER_COUNT_MIN = 1;
/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */
export const DRIVER_COUNT_TYPICAL = [3, 4];
/**
* A corridor yard as the rules see it.
*
* `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD
* 22), which is what makes "is this leg inside the schedule's span" and "does
* this leg cross into Djibouti" answerable without hard-coding station names.
*/
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
/** Dire Dawa is the handover point §1.1 draws the territorial line at. */
export const DIRE_DAWA_CODE = 'DIRE_DAWA';
/**
* The corridor a schedule runs on, as the rules need to see it: every yard by
* id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by
* the caller so these functions stay pure and unit-testable.
*/
export interface CorridorContext {
yards?: Map<string, CorridorYard>;
originOrder?: number;
destinationOrder?: number;
direDawaOrder?: number;
}
/**
* §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa Nagad
* corridor segment exclusively.
*
* Expressed against yards rather than a fixed segment name: a leg is open to a
* Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point
* east of it works without naming the pair in code. The restriction is
* asymmetric on purpose — the document confines Djiboutian drivers but never
* bars Ethiopians from that stretch.
*/
export const legAllowsNationality = (
from: CorridorYard | undefined,
to: CorridorYard | undefined,
nationality: string,
direDawaOrder: number,
): boolean => {
if (nationality !== 'DJIBOUTIAN') return true;
if (!from || !to) return true; // Incomplete leg — a separate rule reports it.
// Both ends must sit at or beyond Dire Dawa, whichever way the train runs.
return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder;
};
/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */
export interface SpecializedRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint for the UI; nothing enforces it. */
typical: number;
/** Why this is required — surfaced verbatim so the demand is explicable. */
reason: string;
}
/** What the consist and its cargo demand, as detected from the schedule. */
export interface CrewDemandInput {
/** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
const listSources = (sources: string[]): string =>
sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : '';
/**
* Turn detected cargo/consist facts into the crew the run must carry.
* Only triggered rows appear, so staff are never asked about cargo not aboard.
*/
export const specializedRequirements = (
demand: CrewDemandInput,
): SpecializedRequirement[] => {
const required: SpecializedRequirement[] = [];
if (demand.hasReeferCargo) {
required.push({
role: TrainCrewRole.REEFER_TECHNICIAN,
min: 1,
typical: 2,
reason: `Reefer cargo on board${listSources(demand.reeferSources)}`,
});
}
if (demand.hasHazmatCargo) {
required.push({
role: TrainCrewRole.HAZMAT_ESCORT,
min: 1,
typical: 2,
reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`,
});
}
if (demand.hasBreakBulkCargo) {
required.push({
role: TrainCrewRole.LASHING_INSPECTOR,
min: 1,
typical: 2,
reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`,
});
}
if (demand.hasLivestockCargo) {
required.push({
role: TrainCrewRole.LIVESTOCK_HANDLER,
min: 1,
typical: 3,
reason: `Livestock shipment on board${listSources(demand.livestockSources)}`,
});
}
return required;
};
/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */
export const technicianRequirement = (
demand: CrewDemandInput,
): SpecializedRequirement => ({
role: TrainCrewRole.TECHNICIAN,
min: demand.hasBadOrderWagon ? 1 : 0,
typical: 3,
reason: demand.hasBadOrderWagon
? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}`
: 'Optional technical maintenance crew',
});
/** One assignment, reduced to what the rules actually read. */
export interface AssignmentFacts {
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
/** The leg this driver works, as two corridor yards. */
fromYardId?: string | null;
toYardId?: string | null;
nationality: string;
memberName: string;
}
export interface CrewValidationIssue {
code: string;
message: string;
}
export interface CrewValidationResult {
/** True when every mandatory rule passes — the dispatch gate reads this. */
complete: boolean;
issues: CrewValidationIssue[];
/** Derived, never entered: one segment covered = short run, both = long run (§1.1). */
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
/**
* Validate a schedule's crew against §1.1 and §1.2.
*
* Returns issues rather than throwing: the wizard renders them as a live
* checklist while a partial crew is still being built, and only the dispatch
* guard treats a non-empty list as fatal.
*/
export const validateCrewComposition = (
assignments: AssignmentFacts[],
demand: CrewDemandInput,
corridor: CorridorContext = {},
): CrewValidationResult => {
const yards = corridor.yards ?? new Map<string, CorridorYard>();
const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY;
const issues: CrewValidationIssue[] = [];
const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER);
if (drivers.length < DRIVER_COUNT_MIN) {
issues.push({
code: 'DRIVER_COUNT',
message: 'At least one driver must be assigned',
});
}
// Every driver needs a leg and a duty role — without them the run has no
// record of who drove which part of the corridor.
for (const driver of drivers) {
if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) {
issues.push({
code: 'DRIVER_SLOT_INCOMPLETE',
message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`,
});
continue;
}
if (driver.fromYardId === driver.toYardId) {
issues.push({
code: 'DRIVER_LEG_EMPTY',
message: `${driver.memberName} has the same yard at both ends of their leg`,
});
}
// A leg outside the schedule's own span would put a driver on track this
// train never runs.
if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) {
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
const from = yards.get(driver.fromYardId);
const to = yards.get(driver.toYardId);
for (const yard of [from, to]) {
if (yard && (yard.displayOrder < low || yard.displayOrder > high)) {
issues.push({
code: 'LEG_OUTSIDE_ROUTE',
message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`,
});
}
}
}
}
// A leg cannot have two Primaries — someone must be in charge of each stretch
// and only one person can be. Assistants and relief drivers are unconstrained.
const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`;
const legLabel = (d: AssignmentFacts) => {
const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined;
const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined;
return from && to ? `${from} ${to}` : 'this leg';
};
const primariesByLeg = new Map<string, { names: string[]; label: string }>();
for (const driver of drivers) {
if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) {
const key = legKey(driver);
const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) };
entry.names.push(driver.memberName);
primariesByLeg.set(key, entry);
}
}
for (const [, entry] of primariesByLeg) {
if (entry.names.length > 1) {
issues.push({
code: 'DUPLICATE_PRIMARY',
message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`,
});
}
}
// Each covered leg needs a Primary — an Assistant alone cannot run it.
const coveredLegs = new Map<string, string>();
for (const driver of drivers) {
if (driver.fromYardId && driver.toYardId) {
coveredLegs.set(legKey(driver), legLabel(driver));
}
}
for (const [key, label] of coveredLegs) {
if (!primariesByLeg.has(key)) {
issues.push({
code: 'PRIMARY_MISSING',
message: `${label} has no Primary Driver assigned`,
});
}
}
// §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa.
for (const driver of drivers) {
const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined;
const to = driver.toYardId ? yards.get(driver.toYardId) : undefined;
if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) {
issues.push({
code: 'TERRITORIAL_BOUNDARY',
message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
});
}
}
// §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are
// no longer enforced: operations crew each run to its own need, so any number
// of any role is accepted. What still holds is what makes a run coherent —
// a driver with a segment and duty role, one Primary per segment, and the
// specialized crew the cargo actually demands.
// §1.2 technical maintenance crew: a bad-order wagon still forces at least
// one technician — that rule is about safety, not crew sizing, so it stays.
const technicianRule = technicianRequirement(demand);
const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length;
if (technicians < technicianRule.min) {
issues.push({
code: 'TECHNICIAN_REQUIRED',
message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`,
});
}
// §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort
// rides along) but the upper bound is gone — how many is operations' call.
for (const rule of specializedRequirements(demand)) {
const count = assignments.filter((a) => a.role === rule.role).length;
if (count < rule.min) {
issues.push({
code: 'SPECIALIZED_REQUIRED',
message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`,
});
}
}
// Nobody may hold two seats on the same run.
const seen = new Set<string>();
for (const a of assignments) {
if (seen.has(a.crewMemberId)) {
issues.push({
code: 'DUPLICATE_MEMBER',
message: `${a.memberName} is assigned more than once on this run`,
});
}
seen.add(a.crewMemberId);
}
return {
complete: issues.length === 0,
issues,
runType: deriveRunType(drivers, corridor, yards),
};
};
/**
* §1.1 run type. A crew whose legs together span the schedule's whole route is
* a long run; anything shorter is a short run.
*/
const deriveRunType = (
drivers: AssignmentFacts[],
corridor: CorridorContext,
yards: Map<string, CorridorYard>,
): 'SHORT_RUN' | 'LONG_RUN' | null => {
const orders = drivers
.flatMap((d) => [d.fromYardId, d.toYardId])
.map((id) => (id ? yards.get(id)?.displayOrder : undefined))
.filter((o): o is number => o !== undefined);
if (!orders.length) return null;
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return 'SHORT_RUN';
}
const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder);
const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder);
const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh;
return covered ? 'LONG_RUN' : 'SHORT_RUN';
};
export const labelSegment = (segment: CrewSegment): string =>
({
[CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP Dire Dawa',
[CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa Nagad',
[CrewSegment.FULL_CORRIDOR]: 'Full corridor',
})[segment];
export const labelDutyRole = (dutyRole: CrewDutyRole): string =>
({
[CrewDutyRole.PRIMARY]: 'Primary Driver',
[CrewDutyRole.ASSISTANT]: 'Assistant Driver',
[CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver',
})[dutyRole];
export const labelRole = (role: TrainCrewRole): string =>
({
[TrainCrewRole.TRAIN_DRIVER]: 'train driver',
[TrainCrewRole.FEDERAL_POLICE]: 'federal police',
[TrainCrewRole.TECHNICIAN]: 'technician',
[TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician',
[TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort',
[TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector',
[TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler',
})[role];
/**
* §3.2 overtime hours for one driver's month.
*
* Hours only, by design: no salary is stored anywhere in the platform, so the
* output stops at the two tier totals and finance applies the rates.
*/
export const overtimeHours = (
workedHours: number,
standardHours: number = MONTHLY_STANDARD_HOURS,
): { variance: number; tier1Hours: number; tier2Hours: number } => {
const variance = Math.max(0, workedHours - standardHours);
return {
variance,
tier1Hours: variance * OT_TIER_1_SHARE,
tier2Hours: variance * OT_TIER_2_SHARE,
};
};

View File

@@ -0,0 +1,45 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { CrewDutyRole } from '../entities/train-crew-assignment.entity';
import { TrainCrewRole } from '../entities/train-crew-member.entity';
export class CrewAssignmentRowDto {
@IsUUID()
crewMemberId!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
/** Required for drivers, rejected as incomplete without it. */
@IsOptional()
@IsEnum(CrewDutyRole)
dutyRole?: CrewDutyRole;
/** The leg this driver works — any two yards on the schedule's route. */
@IsOptional()
@IsUUID()
fromYardId?: string;
@IsOptional()
@IsUUID()
toYardId?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class SaveCrewAssignmentsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CrewAssignmentRowDto)
assignments!: CrewAssignmentRowDto[];
}

View File

@@ -0,0 +1,114 @@
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity';
/**
* Legacy fixed corridor segments.
*
* Kept only so historic rows written before segments became yard-to-yard still
* read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick
* any two yards on the corridor, so a leg is no longer limited to the three
* spans the original design hard-coded.
*/
export enum CrewSegment {
INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA',
DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD',
FULL_CORRIDOR = 'FULL_CORRIDOR',
}
/** Driver duty role for one run (§2). Null for non-driving crew. */
export enum CrewDutyRole {
PRIMARY = 'PRIMARY',
ASSISTANT = 'ASSISTANT',
BENCH_RELIEF = 'BENCH_RELIEF',
}
export enum CrewAssignmentStatus {
PLANNED = 'PLANNED',
CONFIRMED = 'CONFIRMED',
COMPLETED = 'COMPLETED',
REMOVED = 'REMOVED',
}
/**
* One roster member assigned to one train schedule.
*
* `role` is snapshotted from the roster at assignment time: a member who later
* changes role must not silently rewrite the crew of a run that already
* departed. `dutyRole` and `segment` live here rather than on the roster
* because they are properties of THIS run — a driver who is Primary on one
* trip is Assistant on the next. Crew sizes are free-form: operations size each
* run to its own need rather than to a fixed pairing case.
*
* Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the
* platform reports OT hours only; no salary is stored anywhere, and the payroll
* conversion stays with finance.
*/
@Entity({ schema: 'freight', name: 'train_crew_assignments' })
@Index(['trainScheduleId'])
@Index(['crewMemberId'])
@Index(['status'])
export class TrainCrewAssignment extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'crew_member_id', type: 'uuid' })
crewMemberId!: string;
@ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'crew_member_id' })
crewMember?: TrainCrewMember;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true })
dutyRole?: CrewDutyRole | null;
/** Legacy fixed segment — null on every assignment written since yard legs. */
@Column({ name: 'segment', type: 'varchar', length: 24, nullable: true })
segment?: CrewSegment | null;
/**
* The leg this driver works, as two yards on the corridor.
*
* Free-form on purpose: operations pick any yard as a handover point, so a
* crew change at Meiso or Feto is expressible without a code change. The
* schedule's own origin and destination bound what staff may choose.
*/
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
fromYardId?: string | null;
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
toYardId?: string | null;
/**
* Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours
* as a typical duration, not a rule, so nothing here enforces a length — the
* stamps are recorded and reported.
*/
@Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true })
layoverStartAt?: Date | null;
@Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true })
layoverEndAt?: Date | null;
/** Worked span for this run — accumulated monthly for the §3 OT calculation. */
@Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true })
dutyStartAt?: Date | null;
@Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true })
dutyEndAt?: Date | null;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: CrewAssignmentStatus.PLANNED,
})
status!: CrewAssignmentStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, 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 { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
@ApiTags('train-crew-assignments')
@ApiBearerAuth()
@Controller('train-schedules/:scheduleId/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.assign])
export class TrainCrewAssignmentController {
constructor(private readonly service: TrainCrewAssignmentService) {}
@Get()
@ApiOperation({
summary: "A schedule's crew, the cargo-driven requirements, and rule validation",
})
getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.getScheduleCrew(scheduleId);
}
@Get('eligible-drivers')
@ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' })
eligibleDrivers(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Query('fromYardId') fromYardId?: string,
@Query('toYardId') toYardId?: string,
) {
return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId);
}
@Get('corridor-yards')
@ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" })
corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.corridorYards(scheduleId);
}
@Put()
@BookingStaff(FREIGHT_PERMS.trainCrew.assign)
@ApiOperation({
summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)",
})
save(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: SaveCrewAssignmentsDto,
) {
return this.service.saveAssignments(scheduleId, dto);
}
}

View File

@@ -0,0 +1,389 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import {
CrewAssignmentStatus,
TrainCrewAssignment,
} from './entities/train-crew-assignment.entity';
import {
TrainCrewMember,
TrainCrewRole,
TrainCrewStatus,
} from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
CrewValidationResult,
DIRE_DAWA_CODE,
labelRole,
legAllowsNationality,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
/** Wagon statuses that mean "defective / bad order" for §1.2. */
const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE'];
/**
* Cargo-type name fragments that mark a livestock shipment. Matched on the
* cargo type's name because no boolean flag for livestock exists yet — unlike
* reefer and hazardous, which bookings carry explicitly.
*/
const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry'];
@Injectable()
export class TrainCrewAssignmentService {
constructor(
@InjectRepository(TrainCrewAssignment)
private readonly assignmentRepo: Repository<TrainCrewAssignment>,
@InjectRepository(TrainCrewMember)
private readonly memberRepo: Repository<TrainCrewMember>,
private readonly dataSource: DataSource,
) {}
/** Every assignment on a schedule, with the roster member joined. */
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
return this.assignmentRepo.find({
where: {
trainScheduleId: scheduleId,
status: In([
CrewAssignmentStatus.PLANNED,
CrewAssignmentStatus.CONFIRMED,
CrewAssignmentStatus.COMPLETED,
]),
},
relations: { crewMember: true },
order: { createdAt: 'ASC' },
});
}
/**
* What this schedule's consist and cargo demand (§1.2).
*
* Read straight from the train set and its allocations rather than asked of
* the user: the wagons and bookings already say whether a bad-order wagon is
* attached and whether reefer, hazardous, break-bulk or livestock cargo is
* aboard, so the requirement is derived and every row can name its trigger.
*/
async detectDemand(scheduleId: string): Promise<CrewDemandInput> {
const badOrder: Array<{ label: string }> = await this.dataSource.query(
`
SELECT COALESCE(w.wagon_number, tsw.id::text) AS label
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE ts.id = $1
AND w.status = ANY($2)
`,
[scheduleId, BAD_ORDER_WAGON_STATUSES],
);
const cargo: Array<{
reference: string | null;
is_reefer: boolean;
is_hazardous: boolean;
load_type: string | null;
cargo_type_name: string | null;
}> = await this.dataSource.query(
`
SELECT DISTINCT
b.reference,
b.is_reefer,
b.is_hazardous,
wba.load_type,
ct.cargo_type_name
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id
JOIN freight.bookings b ON b.id = wba.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE ts.id = $1
`,
[scheduleId],
);
const label = (row: { reference: string | null }) => row.reference ?? 'a booking';
const isLivestock = (name: string | null) =>
Boolean(name) &&
LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint));
const reefer = cargo.filter((c) => c.is_reefer);
const hazmat = cargo.filter((c) => c.is_hazardous);
// Break-bulk rides as a bulk allocation rather than a container.
const breakBulk = cargo.filter((c) => c.load_type === 'BULK');
const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name));
return {
hasBadOrderWagon: badOrder.length > 0,
badOrderWagonLabels: badOrder.map((w) => w.label),
hasReeferCargo: reefer.length > 0,
reeferSources: reefer.map(label),
hasHazmatCargo: hazmat.length > 0,
hazmatSources: hazmat.map(label),
hasBreakBulkCargo: breakBulk.length > 0,
breakBulkSources: breakBulk.map(label),
hasLivestockCargo: livestock.length > 0,
livestockSources: livestock.map(label),
};
}
/**
* The corridor this schedule runs on: every active yard by id, plus where the
* schedule starts, ends, and where Dire Dawa sits. `display_order` is the
* yard's place along the line, which is what lets the rules answer "is this
* leg inside the route" and "does it cross the territorial boundary" without
* hard-coding station names.
*/
async loadCorridor(scheduleId: string): Promise<CorridorContext> {
const rows: Array<{
id: string;
code: string;
label: string;
country: string;
display_order: number;
}> = await this.dataSource.query(
`SELECT id, code, label, country, display_order
FROM freight.yards
WHERE is_active = true
ORDER BY display_order ASC`,
);
const yards = new Map<string, CorridorYard>(
rows.map((r) => [
r.id,
{
id: r.id,
label: r.label,
country: r.country,
displayOrder: Number(r.display_order),
},
]),
);
const [schedule]: Array<{
origin_station_id: string | null;
destination_station_id: string | null;
}> = await this.dataSource.query(
`SELECT origin_station_id, destination_station_id
FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
const orderOf = (id: string | null | undefined) =>
id ? yards.get(id)?.displayOrder : undefined;
return {
yards,
originOrder: orderOf(schedule?.origin_station_id),
destinationOrder: orderOf(schedule?.destination_station_id),
direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE)
? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order)
: undefined,
};
}
/** Yards a driver leg may use — every yard between origin and destination. */
async corridorYards(scheduleId: string): Promise<CorridorYard[]> {
const corridor = await this.loadCorridor(scheduleId);
const all = [...(corridor.yards?.values() ?? [])].sort(
(a, b) => a.displayOrder - b.displayOrder,
);
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return all;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high);
}
/**
* Full picture for one schedule: who is assigned, what the cargo demands, and
* which composition rules currently fail. The wizard renders this directly.
*/
async getScheduleCrew(scheduleId: string) {
const [assignments, demand, corridor] = await Promise.all([
this.listForSchedule(scheduleId),
this.detectDemand(scheduleId),
this.loadCorridor(scheduleId),
]);
const validation = validateCrewComposition(
assignments.map(toFacts),
demand,
corridor,
);
return {
scheduleId,
assignments,
corridorYards: [...(corridor.yards?.values() ?? [])]
.filter((y) => {
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return true;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return y.displayOrder >= low && y.displayOrder <= high;
})
.sort((a, b) => a.displayOrder - b.displayOrder),
demand,
requirements: {
technician: technicianRequirement(demand),
specialized: specializedRequirements(demand),
},
validation,
};
}
/**
* Replace a schedule's crew in one transaction.
*
* A whole-set replace rather than per-row edits: the wizard submits the
* finished crew, and composition rules are only meaningful over the complete
* set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster
* over days, and §1.2 places the hard gate at departure, not at save time.
* Only structural errors (unknown member, wrong role, territorial breach)
* reject here; the rest surface as issues and block dispatch.
*/
async saveAssignments(
scheduleId: string,
dto: SaveCrewAssignmentsDto,
): Promise<CrewValidationResult> {
const rows = dto.assignments ?? [];
const memberIds = rows.map((r) => r.crewMemberId);
const corridor = await this.loadCorridor(scheduleId);
const members = memberIds.length
? await this.memberRepo.find({ where: { id: In(memberIds) } })
: [];
const byId = new Map(members.map((m) => [m.id, m]));
for (const row of rows) {
const member = byId.get(row.crewMemberId);
if (!member) {
throw new NotFoundException(`Crew member ${row.crewMemberId} not found`);
}
if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`,
);
}
if (row.role !== member.role) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`,
);
}
if (member.role === TrainCrewRole.TRAIN_DRIVER) {
if (!row.fromYardId || !row.toYardId || !row.dutyRole) {
throw new BadRequestException(
`Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`,
);
}
// §1.1 territorial boundary is structural — never persist a breach.
const from = corridor.yards?.get(row.fromYardId);
const to = corridor.yards?.get(row.toYardId);
if (
!legAllowsNationality(
from,
to,
member.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
)
) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(TrainCrewAssignment);
await repo.delete({ trainScheduleId: scheduleId });
if (rows.length) {
await repo.insert(
rows.map((row) => ({
trainScheduleId: scheduleId,
crewMemberId: row.crewMemberId,
role: row.role,
dutyRole: row.dutyRole ?? null,
fromYardId: row.fromYardId ?? null,
toYardId: row.toYardId ?? null,
status: CrewAssignmentStatus.PLANNED,
notes: row.notes ?? null,
})),
);
}
});
const demand = await this.detectDemand(scheduleId);
const saved = await this.listForSchedule(scheduleId);
return validateCrewComposition(saved.map(toFacts), demand, corridor);
}
/**
* Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule
* listed, so staff see the whole gap at once rather than one error per retry.
*/
async assertCrewReadyForDispatch(scheduleId: string): Promise<void> {
const { validation } = await this.getScheduleCrew(scheduleId);
if (!validation.complete) {
throw new BadRequestException(
`Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`,
);
}
}
/** Roster drivers eligible for a leg between two yards (§1.1). */
async eligibleDrivers(
scheduleId: string,
fromYardId?: string,
toYardId?: string,
): Promise<TrainCrewMember[]> {
const drivers = await this.memberRepo.find({
where: {
role: TrainCrewRole.TRAIN_DRIVER,
status: TrainCrewStatus.ACTIVE,
isActive: true,
},
order: { firstName: 'ASC' },
});
if (!fromYardId || !toYardId) return drivers;
const corridor = await this.loadCorridor(scheduleId);
const from = corridor.yards?.get(fromYardId);
const to = corridor.yards?.get(toYardId);
return drivers.filter((d) =>
legAllowsNationality(
from,
to,
d.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
),
);
}
}
/** Reduce a persisted assignment to the facts the rules read. */
const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({
crewMemberId: a.crewMemberId,
role: a.role,
dutyRole: a.dutyRole,
fromYardId: a.fromYardId,
toYardId: a.toYardId,
nationality: a.crewMember?.nationality ?? '',
memberName: a.crewMember
? `${a.crewMember.firstName} ${a.crewMember.lastName}`
: 'A crew member',
});

View File

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

View File

@@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
@@ -449,6 +450,9 @@ export class TrainSchedulingService {
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
// Trailing + @Optional for the same positional-spec reason as above.
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -3027,6 +3031,11 @@ export class TrainSchedulingService {
'End the loading window at the origin station before dispatching',
);
}
// On-board crew must be complete before the train leaves — ITLMS Rolling
// Stock §1.2 enforces composition "prior to departure", so an incomplete
// crew saves freely on the assignment page but cannot depart. Optional
// dependency: the positional spec constructors omit it.
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');

View File

@@ -17,6 +17,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainCrewModule } from '../train-crew/train-crew.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
@@ -76,6 +77,7 @@ import { ContractsModule } from '../contracts/contracts.module';
forwardRef(() => WarehousesModule),
RuleEngineModule,
forwardRef(() => ContractsModule),
TrainCrewModule,
],
controllers: [TrainSchedulingController],
providers: [

View File

@@ -1531,6 +1531,11 @@ export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:train_crew:delete",
"Delete train crew member",
),
perm(
"f5a00001-0001-4000-8000-000000000005",
"edr_freight_app:train_crew:assign",
"Assign train crew to a schedule",
),
];
// E'. Train-scheduling finer actions (augment existing view/manage)
@@ -2364,6 +2369,7 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
assign: "edr_freight_app:train_crew:assign",
},
tracking: {
view: "edr_freight_app:tracking:view",

View File

@@ -271,6 +271,7 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
assign: "edr_freight_app:train_crew:assign",
},
tracking: {
view: "edr_freight_app:tracking:view",

View File

@@ -1,23 +1,612 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Stepper,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
Plus,
ShieldCheck,
Train,
Trash2,
Users,
Wrench,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
trainCrewService,
trainCrewRoleLabel,
type TrainCrewMember,
type TrainCrewRole,
} from "@/services/trainCrew.service";
import {
DUTY_ROLE_OPTIONS,
trainCrewAssignmentService,
type CorridorYard,
type CrewDutyRole,
} from "@/services/trainCrewAssignment.service";
/**
* Train crew assignment for one schedule.
* One driver row being built. The leg (two yards) and the duty role are
* properties of THIS run, not of the person.
*/
interface DriverRow {
key: string;
crewMemberId: string | null;
fromYardId: string | null;
toYardId: string | null;
dutyRole: CrewDutyRole | null;
}
/**
* A new row pre-filled with the schedule's own endpoints — the common case is
* one driver over the whole route, and staff narrow it from there.
*/
const newDriverRow = (yards: CorridorYard[]): DriverRow => ({
key: `driver-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
crewMemberId: null,
fromYardId: yards[0]?.id ?? null,
toYardId: yards[yards.length - 1]?.id ?? null,
dutyRole: null,
});
/**
* Assign a train crew to one schedule — ITLMS Rolling Stock §1.1 and §1.2.
*
* Intentionally blank: the assignment rules — crew counts per role, the driver
* pairing cases, and which corridor segment each driver covers — are still to
* be specified, so only the route and header exist so far.
* Crew sizes are free-form: operations add as many drivers, police, technicians
* or specialists as a given run needs, rather than filling the fixed pairing
* cases of §2. What is still enforced is what makes a run coherent — every
* driver carries a leg and duty role, one Primary per leg, Djibouti drivers
* confined to Dire Dawa and eastward (§1.1), and the specialized crew the cargo
* actually demands (§1.2).
*
* A partial crew always saves: §1.2 puts the hard gate at departure, so this
* page and the dispatch guard call the same server-side validator.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trainCrew.assign);
const [step, setStep] = useState(0);
const [drivers, setDrivers] = useState<DriverRow[]>([]);
/** Support and specialist picks, keyed by role. */
const [supportIds, setSupportIds] = useState<Record<string, Array<string | null>>>({});
const { data: crew, isLoading } = useQuery({
queryKey: ["schedule-crew", scheduleId],
queryFn: async () => (await trainCrewAssignmentService.get(scheduleId)).data,
enabled: Boolean(scheduleId),
});
const { data: roster = [] } = useQuery({
queryKey: ["train-crew", "roster-all"],
queryFn: async () => {
const res = await trainCrewService.getAll({ limit: 200, status: "ACTIVE" });
return res.data.data;
},
});
// Seed from what is already saved, so reopening resumes rather than restarts.
useEffect(() => {
if (!crew) return;
const driverRows: DriverRow[] = [];
const support: Record<string, Array<string | null>> = {};
for (const a of crew.assignments) {
if (a.role === "TRAIN_DRIVER") {
driverRows.push({
key: a.id,
crewMemberId: a.crewMemberId,
fromYardId: a.fromYardId ?? null,
toYardId: a.toYardId ?? null,
dutyRole: a.dutyRole ?? null,
});
} else {
support[a.role] = [...(support[a.role] ?? []), a.crewMemberId];
}
}
setDrivers(driverRows);
setSupportIds(support);
}, [crew]);
const corridorYards = crew?.corridorYards ?? [];
const yardOptions = useMemo(
() => corridorYards.map((y) => ({ value: y.id, label: y.label })),
[corridorYards],
);
/**
* §1.1 — a leg is open to a Djibouti driver only when both ends sit at or
* beyond Dire Dawa. Position along the corridor answers this without naming
* station pairs, so a handover anywhere east of Dire Dawa works.
*/
const legOpenToDjibouti = (leg: {
fromYardId: string | null;
toYardId: string | null;
}) => {
const boundary = corridorYards.find((y) => /dire dawa/i.test(y.label));
const from = corridorYards.find((y) => y.id === leg.fromYardId);
const to = corridorYards.find((y) => y.id === leg.toYardId);
// An unknown boundary or half-built leg is not a breach — the server-side
// validator reports the incomplete leg on its own.
if (!boundary || !from || !to) return true;
return Math.min(from.displayOrder, to.displayOrder) >= boundary.displayOrder;
};
const byRole = useMemo(() => {
const map = new Map<TrainCrewRole, TrainCrewMember[]>();
for (const m of roster) {
map.set(m.role, [...(map.get(m.role) ?? []), m]);
}
return map;
}, [roster]);
/** Everyone already picked — nobody may hold two seats on one run. */
const takenIds = useMemo(() => {
const ids = [
...drivers.map((d) => d.crewMemberId),
...Object.values(supportIds).flat(),
].filter(Boolean) as string[];
return new Set(ids);
}, [drivers, supportIds]);
const memberOptions = (
role: TrainCrewRole,
currentValue: string | null,
leg?: { fromYardId: string | null; toYardId: string | null },
) =>
(byRole.get(role) ?? [])
.filter((m) => {
// §1.1 territorial boundary: a Djibouti driver never appears on a leg
// they may not work. Enforced by making the invalid choice unavailable
// rather than by rejecting it afterwards.
if (leg && m.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(leg)) {
return false;
}
return m.id === currentValue || !takenIds.has(m.id);
})
.map((m) => ({
value: m.id,
label: `${m.firstName} ${m.lastName} · ${m.nationality === "ETHIOPIAN" ? "ET" : "DJ"}`,
}));
const setDriver = (key: string, patch: Partial<DriverRow>) =>
setDrivers((prev) =>
prev.map((row) => {
if (row.key !== key) return row;
const next = { ...row, ...patch };
// Moving the leg can invalidate the person already chosen — clear
// rather than silently persist a territorial breach.
const legMoved = patch.fromYardId !== undefined || patch.toYardId !== undefined;
if (legMoved && next.crewMemberId) {
const member = roster.find((m) => m.id === next.crewMemberId);
if (member?.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(next)) {
next.crewMemberId = null;
}
}
return next;
}),
);
const setSupportCount = (role: TrainCrewRole, count: number) =>
setSupportIds((prev) => ({
...prev,
[role]: Array.from({ length: count }, (_, i) => prev[role]?.[i] ?? null),
}));
const buildPayload = () => {
const assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}> = [];
for (const row of drivers) {
if (row.crewMemberId) {
assignments.push({
crewMemberId: row.crewMemberId,
role: "TRAIN_DRIVER",
...(row.dutyRole ? { dutyRole: row.dutyRole } : {}),
...(row.fromYardId ? { fromYardId: row.fromYardId } : {}),
...(row.toYardId ? { toYardId: row.toYardId } : {}),
});
}
}
for (const [role, ids] of Object.entries(supportIds)) {
for (const id of ids) {
if (id) assignments.push({ crewMemberId: id, role: role as TrainCrewRole });
}
}
return { assignments };
};
const saveMutation = useMutation({
mutationFn: () => trainCrewAssignmentService.save(scheduleId, buildPayload()),
onSuccess: (res) => {
const validation = res.data;
toast({
title: validation.complete
? "Crew saved — composition complete"
: "Crew saved (still incomplete)",
description: validation.complete
? undefined
: "The train cannot be dispatched until every rule passes.",
});
qc.invalidateQueries({ queryKey: ["schedule-crew", scheduleId] });
},
onError: (error: unknown) => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
toast({
title: "Could not save crew",
description: Array.isArray(message)
? message.join(", ")
: typeof message === "string"
? message
: "The request failed. Please try again.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
const demand = crew?.demand;
const specialized = crew?.requirements.specialized ?? [];
const technicianRule = crew?.requirements.technician;
const validation = crew?.validation;
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
subtitle="Add as many drivers and crew as this run needs"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
meta={
validation ? (
<Badge
variant="light"
color={validation.complete ? "green" : "orange"}
leftSection={
validation.complete ? <CheckCircle2 size={12} /> : <AlertTriangle size={12} />
}
>
{validation.complete ? "Ready to dispatch" : "Incomplete"}
</Badge>
) : null
}
action={
canAssign ? (
<Button
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
color="edr-green"
>
Save Crew
</Button>
) : null
}
/>
<Stepper active={step} onStepClick={setStep} mt="md" size="sm">
<Stepper.Step label="Drivers" description="Any number">
<Stack gap="md" mt="lg">
<Text size="sm" c="dimmed">
Add a row per driver and set the leg they work any two yards on this
schedule's route, so a handover at Feto or Meiso is as easy as one at
Dire Dawa. Djibouti drivers are offered only on legs from Dire Dawa
eastward.
</Text>
{drivers.length === 0 ? (
<Alert color="gray">No drivers added yet.</Alert>
) : (
drivers.map((row, index) => (
<Card key={row.key} withBorder padding="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
<Train size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Driver {index + 1}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove driver"
onClick={() =>
setDrivers((prev) => prev.filter((d) => d.key !== row.key))
}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Group grow align="flex-start" wrap="wrap">
<Select
label="From yard"
placeholder="Start of this leg"
searchable
data={yardOptions}
value={row.fromYardId}
onChange={(val) => setDriver(row.key, { fromYardId: val })}
/>
<Select
label="To yard"
placeholder="End of this leg"
searchable
data={yardOptions}
value={row.toYardId}
onChange={(val) => setDriver(row.key, { toYardId: val })}
/>
<Select
label="Duty role"
placeholder="Select a duty role"
data={DUTY_ROLE_OPTIONS}
value={row.dutyRole}
onChange={(val) =>
setDriver(row.key, { dutyRole: (val as CrewDutyRole) ?? null })
}
/>
<Select
label="Driver"
placeholder="Select a driver"
searchable
clearable
data={memberOptions("TRAIN_DRIVER", row.crewMemberId, row)}
value={row.crewMemberId}
onChange={(val) => setDriver(row.key, { crewMemberId: val })}
/>
</Group>
</Card>
))
)}
<Button
variant="light"
leftSection={<Plus size={16} />}
onClick={() => setDrivers((prev) => [...prev, newDriverRow(corridorYards)])}
>
Add Driver
</Button>
</Stack>
</Stepper.Step>
<Stepper.Step label="Support crew" description="Police, technical, cargo">
<Stack gap="lg" mt="lg">
<SupportSection
role="FEDERAL_POLICE"
icon={<ShieldCheck size={16} />}
color="blue"
title="Security detail"
hint="Add as many federal police as this run needs"
values={supportIds.FEDERAL_POLICE ?? []}
onCount={(n) => setSupportCount("FEDERAL_POLICE", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
FEDERAL_POLICE: (prev.FEDERAL_POLICE ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions("FEDERAL_POLICE", value)}
/>
<SupportSection
role="TECHNICIAN"
icon={<Wrench size={16} />}
color="orange"
title="Technical maintenance crew"
hint={technicianRule?.reason ?? "Optional technical maintenance crew"}
alert={
demand?.hasBadOrderWagon
? "A defective wagon is attached, so at least one technician is mandatory."
: undefined
}
values={supportIds.TECHNICIAN ?? []}
onCount={(n) => setSupportCount("TECHNICIAN", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
TECHNICIAN: (prev.TECHNICIAN ?? []).map((v, idx) => (idx === i ? val : v)),
}))
}
options={(value) => memberOptions("TECHNICIAN", value)}
/>
{specialized.length ? (
specialized.map((rule) => (
<SupportSection
key={rule.role}
role={rule.role}
icon={<Users size={16} />}
color="grape"
title={trainCrewRoleLabel(rule.role)}
hint={rule.reason}
values={supportIds[rule.role] ?? []}
onCount={(n) => setSupportCount(rule.role, n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
[rule.role]: (prev[rule.role] ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions(rule.role, value)}
/>
))
) : (
<Alert color="gray">
No specialized cargo detected on this train no reefer, HAZMAT, break-bulk
or livestock crew is required.
</Alert>
)}
</Stack>
</Stepper.Step>
<Stepper.Completed>
<Stack gap="md" mt="lg">
<Card withBorder padding="lg">
<Text fw={600} mb="sm">
Composition checklist
</Text>
{validation?.complete ? (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="green" variant="light">
<CheckCircle2 size={14} />
</ThemeIcon>
<Text size="sm">Every rule passes this train may be dispatched.</Text>
</Group>
) : (
<Stack gap="xs">
{validation?.issues.map((issue) => (
<Group key={`${issue.code}-${issue.message}`} gap="xs" wrap="nowrap">
<ThemeIcon size={22} radius="xl" color="orange" variant="light">
<AlertTriangle size={14} />
</ThemeIcon>
<Text size="sm">{issue.message}</Text>
</Group>
))}
</Stack>
)}
</Card>
{validation?.runType ? (
<Text size="sm" c="dimmed">
Derived run type:{" "}
<Text span fw={600}>
{validation.runType === "LONG_RUN" ? "Long run" : "Short run"}
</Text>
</Text>
) : null}
</Stack>
</Stepper.Completed>
</Stepper>
<Group justify="space-between" mt="xl">
<Button variant="light" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
<Button variant="light" disabled={step > 1} onClick={() => setStep((s) => s + 1)}>
Next
</Button>
</Group>
</PageContainer>
);
}
/** A crew block: add/remove rows freely, each naming one person. */
function SupportSection({
role,
icon,
color,
title,
hint,
alert,
values,
onCount,
onPick,
options,
}: {
role: TrainCrewRole;
icon: React.ReactNode;
color: string;
title: string;
hint: string;
alert?: string;
values: Array<string | null>;
onCount: (count: number) => void;
onPick: (index: number, value: string | null) => void;
options: (currentValue: string | null) => Array<{ value: string; label: string }>;
}) {
return (
<Card withBorder padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size={32} radius="md" variant="light" color={color}>
{icon}
</ThemeIcon>
<div>
<Text fw={600}>{title}</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</div>
</Group>
{alert ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} mb="md">
{alert}
</Alert>
) : null}
<Stack gap="sm">
{values.map((value, index) => (
<Group key={index} align="flex-end" wrap="nowrap">
<Select
label={`${trainCrewRoleLabel(role)} ${index + 1}`}
placeholder="Select a crew member"
searchable
clearable
data={options(value)}
value={value}
onChange={(val) => onPick(index, val)}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove"
onClick={() => {
const next = values.filter((_, i) => i !== index);
onCount(next.length);
next.forEach((v, i) => onPick(i, v));
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => onCount(values.length + 1)}
style={{ alignSelf: "flex-start" }}
>
Add {trainCrewRoleLabel(role)}
</Button>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,104 @@
import { api as apiClient } from '../auth/http';
import type { TrainCrewMember, TrainCrewRole } from './trainCrew.service';
export type CrewDutyRole = 'PRIMARY' | 'ASSISTANT' | 'BENCH_RELIEF';
/** A yard on the schedule's route, ordered along the corridor. */
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
export interface CrewAssignment {
id: string;
trainScheduleId: string;
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
fromYardId?: string | null;
toYardId?: string | null;
status: string;
crewMember?: TrainCrewMember;
}
/** What the consist and its cargo demand (§1.2), detected server-side. */
export interface CrewDemand {
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
export interface CrewRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint only; nothing enforces it. */
typical: number;
reason: string;
}
export interface CrewValidation {
complete: boolean;
issues: Array<{ code: string; message: string }>;
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
export interface ScheduleCrewResponse {
scheduleId: string;
assignments: CrewAssignment[];
/** Yards a driver leg may use — bounded by the schedule's own endpoints. */
corridorYards: CorridorYard[];
demand: CrewDemand;
requirements: {
technician: CrewRequirement;
specialized: CrewRequirement[];
};
validation: CrewValidation;
}
export interface SaveCrewPayload {
assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}>;
}
const base = (scheduleId: string) => `/train-schedules/${scheduleId}/crew`;
export const trainCrewAssignmentService = {
get: (scheduleId: string) =>
apiClient.get<ScheduleCrewResponse>(base(scheduleId)),
eligibleDrivers: (scheduleId: string, fromYardId: string, toYardId: string) =>
apiClient.get<TrainCrewMember[]>(
`${base(scheduleId)}/eligible-drivers?fromYardId=${fromYardId}&toYardId=${toYardId}`,
),
corridorYards: (scheduleId: string) =>
apiClient.get<CorridorYard[]>(`${base(scheduleId)}/corridor-yards`),
save: (scheduleId: string, payload: SaveCrewPayload) =>
apiClient.put<CrewValidation>(base(scheduleId), payload),
};
export const DUTY_ROLE_OPTIONS: Array<{ value: CrewDutyRole; label: string }> = [
{ value: 'PRIMARY', label: 'Primary Driver' },
{ value: 'ASSISTANT', label: 'Assistant Driver' },
{ value: 'BENCH_RELIEF', label: 'Bench/Relief Driver' },
];
export const dutyRoleLabel = (dutyRole: CrewDutyRole): string =>
({
PRIMARY: 'Primary Driver',
ASSISTANT: 'Assistant Driver',
BENCH_RELIEF: 'Bench/Relief Driver',
})[dutyRole];