Merge pull request #870 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-21 13:26:16 +03:00
committed by GitHub
58 changed files with 1566 additions and 261 deletions

View File

@@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Configured rail distance between two yards (Configuration → Yard Distances).
* Route creation resolves each segment's km from here (symmetric lookup:
* one A↔B row serves both directions) instead of accepting free-text km,
* and snapshots the value onto route_milestones.distance_km.
*
* Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair
* can be re-created.
*/
export class CreateYardDistances2060000000000 implements MigrationInterface {
name = 'CreateYardDistances2060000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_distances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
from_yard_id uuid NOT NULL REFERENCES freight.yards(id),
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
distance_km numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard
ON freight.yard_distances (from_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard
ON freight.yard_distances (to_yard_id);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair
ON freight.yard_distances (from_yard_id, to_yard_id)
WHERE deleted_at IS NULL;
`);
// Backfill from segments already stored on existing routes so editing them
// does not immediately fail the "pair not configured" check. One row per
// unordered pair; where routes disagree the longest segment wins.
await queryRunner.query(`
INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km)
SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id))
prev_yard_id, yard_id, distance_km
FROM (
SELECT
yard_id,
distance_km,
LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id
FROM freight.route_milestones
WHERE deleted_at IS NULL
) segments
WHERE prev_yard_id IS NOT NULL
AND distance_km IS NOT NULL
AND distance_km > 0
ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC
ON CONFLICT DO NOTHING;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`);
}
}

View File

@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
});
}
/** Intercity documents approved → booking waits in the ride-along pool. */
intercityDocumentsApproved(b: Booking): void {
const msg =
`Documents for intercity booking ${b.reference} are approved. ` +
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
this.inApp(b, 'Documents approved', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =

View File

@@ -840,6 +840,22 @@ export class BookingTransitionService {
}
}
// Intercity: there is no shipment-day request step — an approved booking
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
// pool keys on. Staff then accept it onto a passing train (that accept
// opens the pay window).
if (booking.tradeDirection === "DOMESTIC") {
const now = new Date();
await this.bookingsRepository.update(bookingId, {
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.intercityDocumentsApproved(fresh);
return fresh;
}
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);

View File

@@ -1,7 +1,10 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
clearanceCodesForBooking,
INTERCITY_DOCUMENTS_SETTING_CODE,
} from './clearance.util';
import type { Booking } from './entities/booking.entity';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
});
});
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
const base = {
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
serviceType: null,
customsClearingEnabled: false,
};
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
const general = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'GENERAL',
} as unknown as Booking);
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(general.outputCode).toBeNull();
const direct = clearanceCodesForBooking({
...base,
contractId: null,
contractKind: null,
} as unknown as Booking);
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
return null; // DOMESTIC / intercity — no customs operation
}
function freightFor(freightType: string): Freight {
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
freightType: string,
includesCustoms: boolean,
): string | null {
// Intercity: no customs, but the admin-configured intercity document set is
// still collected and ops-reviewed before the shipment may board a train.
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -198,11 +198,12 @@ export class ContractBookingService {
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at

View File

@@ -1,4 +1,5 @@
import { Contract } from './entities/contract.entity';
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
/**
* Resolves which seeded clearance FileUploadSetting applies to a contract during
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
* gate and collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);

View File

@@ -143,6 +143,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their
* contract simply stays "under approval".
*/
sentBackToStep(c: Contract, targetRole: string, reason: string): void {
this.inAppStaff(
c,
'Contract returned in approval chain',
`Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`,
);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =

View File

@@ -41,6 +41,7 @@ import {
ContractDocumentSnapshotInput,
} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/** The editable contract-document draft returned for the accept/edit dialog. */
@@ -576,17 +577,24 @@ export class ContractTransitionService {
/**
* Reject one approval step (line staff / director / CEO). The rejecting
* approver must supply a reason. A rejection is terminal: the whole contract
* moves to REJECTED and the customer must create a new one — there is no
* resubmit of the same contract. The reason is recorded both on the step and
* as a REJECTION review note so it is visible to the customer and the rest of
* the approval chain.
* approver must supply a reason, and picks where the rejection lands:
*
* - **To the customer** (`returnToStepId` omitted — the only option for the
* first approver): terminal. The whole contract moves to REJECTED with a
* REJECTION review note visible to the customer, who must resubmit.
* - **To an earlier approver** (`returnToStepId` = an already-APPROVED
* earlier step): internal send-back. That step and everything after it
* reset to PENDING and the chain re-runs from there; the contract stays
* PENDING_APPROVAL and the customer never sees it. E.g. the director can
* return a contract to line staff, who fix it and approve again, after
* which every later stage re-approves in order.
*/
async rejectStep(
contractId: string,
stepId: string,
actorId: string,
reason: string,
returnToStepId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
@@ -594,6 +602,20 @@ export class ContractTransitionService {
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step) throw new BadRequestException('Approval step not found');
// Only the approver whose turn it is may reject — same ordering rule as
// approveStep. Without this, an already-actioned or future step could be
// "rejected" and wipe chain state it never owned.
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Only the current pending approval step can be rejected',
);
}
if (returnToStepId) {
return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
await this.contractsRepository.createReviewNote(
@@ -616,6 +638,67 @@ export class ContractTransitionService {
return updated;
}
/**
* Internal send-back branch of rejectStep: return the contract to an earlier,
* already-approved stage of the chain instead of rejecting it outright.
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
* is still alive) and no customer-facing REJECTION note — the trail is a
* staff note plus a backoffice inbox ping.
*/
private async sendBackToStep(
contract: Contract,
rejectingStep: ContractApprovalStep,
actorId: string,
reason: string,
returnToStepId: string,
): Promise<Contract> {
const target = await this.contractsRepository.findApprovalStepById(
contract.id,
returnToStepId,
);
if (!target) throw new BadRequestException('Return-to approval step not found');
if (target.stepOrder >= rejectingStep.stepOrder) {
throw new BadRequestException(
'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
);
}
if (target.status !== 'APPROVED') {
throw new BadRequestException(
`Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
);
}
// Staff-visible trail. Written before the reset so the reason survives the
// wipe of per-step notes.
await this.contractsRepository.createReviewNote(
contract.id,
`Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
'STAFF_NOTE',
actorId,
'STAFF',
);
// Chain re-runs from the target stage: it and every later step (including
// the rejecting one) go back to PENDING. Legacy approved-by columns are
// left stale on purpose — approval steps are the source of truth and the
// columns get re-stamped on re-approval.
await this.contractsRepository.resetApprovalStepsFrom(
contract.id,
target.stepOrder,
);
// A send-back can only happen mid-chain, so the contract must remain (or
// return to) PENDING_APPROVAL — relevant when rejecting from
// APPROVED_PENDING_SIGNATURE.
await this.contractsRepository.update(contract.id, {
status: 'PENDING_APPROVAL',
} as never);
const updated = await this.contractsService.findById(contract.id);
this.notifier.sentBackToStep(updated, target.requiredRole, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,
@@ -1035,8 +1118,8 @@ export class ContractTransitionService {
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,

View File

@@ -442,7 +442,10 @@ export class ContractsController {
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
@ApiOperation({
summary:
'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)',
})
rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@@ -454,6 +457,7 @@ export class ContractsController {
stepId,
resolveAuthUserId(user),
dto.reason,
dto.returnToStepId,
);
}

View File

@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
await this.attachClearancePhases(items);
await this.attachRejectionNotes(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
}
/**
* Attach the latest REJECTION review-note body to each REJECTED contract so
* list consumers (portal rows, backoffice queues) can show why without a
* per-contract detail fetch. One query per page, like `attachContractFiles`.
*/
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
const rejected = contracts.filter((c) => c.status === 'REJECTED');
if (rejected.length === 0) return;
const ids = rejected.map((c) => c.id);
const rows: Array<{ contract_id: string; body: string }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, body
FROM freight.contract_review_notes
WHERE contract_id = ANY($1)
AND note_type = 'REJECTION'
AND deleted_at IS NULL
ORDER BY contract_id, created_at DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
for (const contract of rejected) {
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')
@@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
});
}
/**
* Send-back reset: every step at or after `fromStepOrder` returns to PENDING
* with its actor/verdict cleared, so the chain re-runs from that stage. The
* send-back reason lives in the review-note trail, not on the wiped steps.
*/
async resetApprovalStepsFrom(
contractId: string,
fromStepOrder: number,
): Promise<void> {
await this.dataSource
.getRepository(ContractApprovalStep)
.createQueryBuilder()
.update()
.set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null })
.where('contract_id = :contractId', { contractId })
.andWhere('step_order >= :fromStepOrder', { fromStepOrder })
.execute();
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({

View File

@@ -642,6 +642,47 @@ export class ContractsService {
}
}
// Surface the rejection reason. The approval-step note is wiped on
// send-back resets, so the review-note trail is the only durable source.
if (contract.status === 'REJECTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'REJECTION',
);
contract.latestRejectionNote = note?.body ?? null;
} catch {
contract.latestRejectionNote = null;
}
}
// Surface the send-back reason to the returned-to approver, but only while
// it is still actionable: once any step acts after the send-back the note
// is stale and stays out of the response (the trail keeps it in the DB).
if (contract.status === 'PENDING_APPROVAL') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'STAFF_NOTE',
);
// Stale when any step acted after it (send-back resolved) or when the
// chain itself is newer than the note (fresh cycle after a resubmit).
const staleAfter = Math.max(
0,
...(contract.approvalSteps ?? []).flatMap((s) => [
s.actedAt ? new Date(s.actedAt).getTime() : 0,
s.createdAt ? new Date(s.createdAt).getTime() : 0,
]),
);
contract.latestSendBackNote =
note && new Date(note.createdAt).getTime() > staleAfter
? note.body
: null;
} catch {
contract.latestSendBackNote = null;
}
}
return contract;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
export class ApproveStepDto {
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@@ -26,6 +26,22 @@ export class RejectStepDto {
@IsString()
@MinLength(1)
reason!: string;
/**
* Where the rejection lands. Omitted → the customer: the contract goes to
* REJECTED and the customer must resubmit (unchanged legacy behaviour, and
* the only option for the first approver in the chain). Set to an EARLIER
* approved step's id → send-back: that step and everything after it reset to
* PENDING and the chain re-runs from there; the contract never leaves
* PENDING_APPROVAL and the customer is not involved.
*/
@ApiPropertyOptional({
description:
'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.',
})
@IsOptional()
@IsUUID()
returnToStepId?: string;
}
export class CancelContractDto {

View File

@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
/**
* Body of the most recent REJECTION review note, attached by
* ContractsService.findById when status is REJECTED so both backoffice and
* portal can show why. Lives in contract_review_notes, not a column here.
*/
latestRejectionNote?: string | null;
/**
* Body of the most recent send-back STAFF_NOTE, attached by
* ContractsService.findById while the contract is PENDING_APPROVAL and no
* approval step has acted since the send-back. Lives in
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
}

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import {
LOCOMOTIVE_STATUSES,
@@ -21,4 +22,29 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
currentYardId?: string;
/**
* Drop locomotives already coupled to a built train — the train-builder
* "change locomotives" picker uses this so a loco that belongs to another
* train is never offered (the backend would 409 on save anyway). Combine with
* `excludeTrainId` to keep the CURRENT train's own locos in the list.
*/
@ApiPropertyOptional({
description: 'Exclude locomotives already coupled to any built train',
})
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
excludeCoupled?: boolean;
/**
* When `excludeCoupled` is set, locos coupled to THIS train are still kept
* (they are valid picks — you are editing that train's consist).
*/
@ApiPropertyOptional({
description: 'Train id whose own coupled locomotives are NOT excluded',
})
@IsOptional()
@IsUUID()
excludeTrainId?: string;
}

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
@@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
super(repository);
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
.orderBy('locomotive.code', 'ASC');
if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status });
if (opts.locomotiveType)
qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType });
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
const sub = this.repository.manager
.getRepository(TrainLocomotive)
.createQueryBuilder('tl')
.select('1')
.where('tl.locomotiveId = locomotive.id');
if (opts.keepTrainId) {
sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId });
}
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
}
/**
* A live locomotive already holding this name, compared the same way the
* `UQ_locomotives_name_active` index compares: case- and whitespace-

View File

@@ -29,6 +29,17 @@ export class LocomotivesService {
}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
// The coupling picker needs a NOT-EXISTS against the train link table, so it
// takes the query-builder path; the plain list keeps the simple where.
if (filter.excludeCoupled) {
return this.locomotivesRepository.findForCoupling({
status: filter.status as LocomotiveStatus | undefined,
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
currentYardId: filter.currentYardId,
excludeCoupled: true,
keepTrainId: filter.excludeTrainId,
});
}
return this.locomotivesRepository.findAll({
where: {
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),

View File

@@ -4,25 +4,22 @@ import {
ArrayMinSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
/**
* Segment distances are no longer part of the payload — they are resolved
* from the configured yard_distances table (Configuration → Yard Distances)
* and snapshotted onto route_milestones at create/update.
*/
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
@IsOptional()
@IsNumber()
@Min(0)
distanceKm?: number;
}
export class CreateRouteDto {

View File

@@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
@Injectable()
export class RoutesService {
constructor(
@@ -183,47 +187,63 @@ export class RoutesService {
return this.findById(id);
}
private async validateMilestones(
milestones: Array<{ yardId: string; distanceKm?: number }>,
) {
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => {
const distanceKm =
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
throw new BadRequestException(
`Enter segment KM for stop ${index + 1} (from previous yard).`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm,
};
});
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
for (const milestone of milestones) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const distanceByPair = await this.loadDistanceLookup(uniqueYardIds);
// Segment km come from the configured yard-distance table, not the payload
// — a route can only be built over pairs an admin has entered. Distances
// are symmetric, so an A→B row also serves B→A.
const missingPairs: string[] = [];
const normalized = milestones.map((milestone, index) => {
if (index === 0) {
return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 };
}
const previousYardId = milestones[index - 1].yardId;
const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId));
if (distanceKm == null) {
const from = yardById.get(previousYardId);
const to = yardById.get(milestone.yardId);
missingPairs.push(
`${from?.label ?? previousYardId}${to?.label ?? milestone.yardId}`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm: distanceKm ?? null,
};
});
if (missingPairs.length > 0) {
throw new BadRequestException(
`No distance configured for: ${missingPairs.join(', ')}. ` +
'Add the missing yard distances in Configuration → Yard Distances first.',
);
}
const originYardId = milestones[0].yardId;
const destinationYardId = milestones[milestones.length - 1].yardId;
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
@@ -236,4 +256,17 @@ export class RoutesService {
milestones: normalized,
};
}
/** Order-insensitive pair → km map over every configured distance touching the yards. */
private async loadDistanceLookup(yardIds: string[]): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(YardDistance)
.find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });
const lookup = new Map<string, number>();
for (const row of rows) {
lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return lookup;
}
}

View File

@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistancesService } from '../services/yard-distances.service';
@ApiTags('yard-distances')
@Controller('yard-distances')
@ApiBearerAuth()
export class YardDistancesController {
constructor(private readonly service: YardDistancesService) {}
@Get()
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'List yard distances' })
findAll(@Query() query: ListYardDistancesQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@RuleEngineView('yard-distances')
@ApiOperation({ summary: 'Get a yard distance by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Create a yard distance' })
create(@Body() dto: CreateYardDistanceDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yard-distances')
@ApiOperation({ summary: 'Update a yard distance' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yard-distances')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard distance' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, IsUUID, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
export class CreateYardDistanceDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
fromYardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
toYardId!: string;
@ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 })
@Transform(toNumber)
@IsNumber()
@Min(0.01)
distanceKm!: number;
}

View File

@@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto {
sortBy?: string;
}
export class ListYardDistancesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Return only distances touching this yard.' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'distanceKm'])
sortBy?: string;
}
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
@IsOptional()

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateYardDistanceDto } from './create-yard-distance.dto';
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* Configured rail distance between two yards. Route creation reads segment
* kilometres from here (symmetric: A→B serves B→A too) instead of taking
* them as free-text input — see RoutesService.validateMilestones.
*
* Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a
* soft-deleted pair can be re-created.
*/
@Entity({ schema: 'freight', name: 'yard_distances' })
@Index(['fromYardId'])
@Index(['toYardId'])
export class YardDistance extends BaseEntity {
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'from_yard_id' })
fromYard?: Yard;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'to_yard_id' })
toYard?: Yard;
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 })
distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm
}

View File

@@ -0,0 +1,17 @@
import { PaginatedResponse } from '@edr/types';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
export interface IYardDistancesRepository {
findById(id: string): Promise<YardDistance | null>;
/** Exact or reverse pair — distances are symmetric (A→B serves B→A). */
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null>;
/** All rows touching any of the given yards, for batch segment lookups. */
findTouchingYards(yardIds: string[]): Promise<YardDistance[]>;
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>>;
create(data: Partial<YardDistance>): Promise<YardDistance>;
update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null>;
softDelete(id: string): Promise<void>;
}
export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY');

View File

@@ -0,0 +1,87 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { Brackets, DataSource, In, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface';
@Injectable()
export class YardDistancesRepository implements IYardDistancesRepository {
private readonly repo: Repository<YardDistance>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(YardDistance);
}
findById(id: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: { id },
relations: { fromYard: true, toYard: true },
});
}
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null> {
return this.repo.findOne({
where: [
{ fromYardId, toYardId },
{ fromYardId: toYardId, toYardId: fromYardId },
],
});
}
findTouchingYards(yardIds: string[]): Promise<YardDistance[]> {
if (!yardIds.length) return Promise.resolve([]);
return this.repo.find({
where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }],
});
}
/** Paged list with server-side search on either yard's label/code. */
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>> {
const qb = this.repo
.createQueryBuilder('yardDistance')
.leftJoinAndSelect('yardDistance.fromYard', 'fromYard')
.leftJoinAndSelect('yardDistance.toYard', 'toYard')
.orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC')
.addOrderBy('fromYard.label', 'ASC');
if (query.yardId) {
qb.andWhere(
new Brackets((w) =>
w
.where('yardDistance.fromYardId = :yardId', { yardId: query.yardId })
.orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }),
),
);
}
if (query.search) {
qb.andWhere(
new Brackets((w) =>
w
.where('fromYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` })
.orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }),
),
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<YardDistance>): Promise<YardDistance> {
const entity = this.repo.create(data);
const saved = await this.repo.save(entity);
return (await this.findById(saved.id)) ?? saved;
}
async update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardDistancesController } from './controllers/yard-distances.controller';
import { YardsController } from './controllers/yards.controller';
import { ApprovalRule } from './entities/approval-rule.entity';
@@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
@@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface';
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
@@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { YardDistancesRepository } from './repositories/yard-distances.repository';
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
@@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardDistancesService } from './services/yard-distances.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
@@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceType,
WeightLimitRule,
Yard,
YardDistance,
YardFacility,
ShippingLine,
Rate,
@@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesController,
WeightLimitRulesController,
YardsController,
YardDistancesController,
ShippingLinesController,
RatesController,
ApprovalRulesController,
@@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
YardsRepository,
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
YardDistancesRepository,
{ provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository },
ShippingLinesRepository,
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
RatesRepository,
@@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ServiceTypesService,
WeightLimitRulesService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
WeightLimitRulesService,
PriorityConfigsService,
YardsService,
YardDistancesService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
@@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
SERVICE_TYPES_REPOSITORY,
SHIPPING_LINES_REPOSITORY,
YARDS_REPOSITORY,
YARD_DISTANCES_REPOSITORY,
],
})
export class RuleEngineModule {}

View File

@@ -0,0 +1,119 @@
import { PaginatedResponse } from '@edr/types';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
import { YardDistance } from '../entities/yard-distance.entity';
import {
IYardDistancesRepository,
YARD_DISTANCES_REPOSITORY,
} from '../interfaces/yard-distances.repository.interface';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/**
* Flat row shape for the backoffice config table: the yard relations stay for
* API consumers, plus label fields the generic rule-engine grid can render.
*/
export type YardDistanceRow = YardDistance & {
fromYardLabel: string;
toYardLabel: string;
};
const yardDisplay = (yard?: { label?: string; code?: string } | null): string =>
yard?.label ?? yard?.code ?? '—';
const toRow = (entity: YardDistance): YardDistanceRow =>
Object.assign(entity, {
fromYardLabel: yardDisplay(entity.fromYard),
toYardLabel: yardDisplay(entity.toYard),
});
@Injectable()
export class YardDistancesService {
constructor(
@Inject(YARD_DISTANCES_REPOSITORY)
private readonly repository: IYardDistancesRepository,
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
) {}
async findAll(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistanceRow>> {
const page = await this.repository.findPaged(query);
return { ...page, items: page.items.map(toRow) };
}
async findById(id: string): Promise<YardDistanceRow> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(entity);
}
async create(dto: CreateYardDistanceDto): Promise<YardDistanceRow> {
await this.assertValidPair(dto.fromYardId, dto.toYardId);
const created = await this.repository.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
distanceKm: dto.distanceKm.toFixed(2),
});
return toRow(created);
}
async update(id: string, dto: UpdateYardDistanceDto): Promise<YardDistanceRow> {
const existing = await this.findById(id);
const fromYardId = dto.fromYardId ?? existing.fromYardId;
const toYardId = dto.toYardId ?? existing.toYardId;
if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) {
await this.assertValidPair(fromYardId, toYardId, id);
}
const updated = await this.repository.update(id, {
fromYardId,
toYardId,
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}),
});
if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
return toRow(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
/**
* Both yards must exist and differ, and the pair must not already be
* configured in either direction — distances are symmetric, so an A→B row
* already covers B→A.
*/
private async assertValidPair(
fromYardId: string,
toYardId: string,
ignoreId?: string,
): Promise<void> {
if (fromYardId === toYardId) {
throw new BadRequestException('From and to yards must be different');
}
const [fromYard, toYard] = await Promise.all([
this.yardsRepository.findById(fromYardId),
this.yardsRepository.findById(toYardId),
]);
if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`);
if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`);
const existing = await this.repository.findBetween(fromYardId, toYardId);
if (existing && existing.id !== ignoreId) {
throw new ConflictException(
`A distance between ${fromYard.label} and ${toYard.label} is already configured`,
);
}
}
}

View File

@@ -29,6 +29,9 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/** Locomotive statuses that block a train from reactivating. */
const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']);
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
@@ -596,11 +599,30 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
/**
* Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled
* again. Blocked if any coupled locomotive is unfit for service — a
* deactivated train can sit parked for a while and its locomotives may have
* since been sent to maintenance independently; reactivating must not wave
* a down locomotive back onto the schedule board.
*/
async activate(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) {
const links = await this.dataSource
.getRepository(TrainLocomotive)
.find({ where: { trainId: id }, relations: { locomotive: true } });
const unfit = links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco))
.filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status));
if (unfit.length) {
const names = unfit.map((l) => `${l.code} (${l.status})`).join(', ');
throw new ConflictException(
`Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`,
);
}
await this.dataSource
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Available });

View File

@@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Intercity documents ─────────────────────────────────────────────────────
// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations.
// ONE_TIME contracts collect it at contract level after both signatures;
// GENERAL contracts collect it per booking right after the booking is created.
// Fields start empty and are configured in the backoffice file-settings editor.
const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "intercity_documents",
label: "Intercity documents",
entity: "booking",
fields: [],
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
})),
];
// Insert setting rows only — no FileUploadField rows. Fields start empty

View File

@@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'priority-configs',
'rates',
'approval-rules',
'yard-distances',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
};
/**

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import { captureApiError } from "@/lib/posthog";
@@ -10,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -92,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -108,16 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
// const payload = extractApiErrorPayload(error);
// if (payload) emitApiError(payload);
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -8,6 +8,7 @@ import {
Button,
Box,
Modal,
Select,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
// earlier APPROVED step to send the chain back to. First approver has no
// choice — customer only.
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
const steps = useMemo(
() =>
@@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// The card also renders read-only trails (e.g. a REJECTED contract) — only
// offer approve/reject while the backend accepts step actions.
const actionable =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
@@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectTarget("CUSTOMER");
setRejectOpen(true);
};
@@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
setRejectTarget("CUSTOMER");
};
const trimmedReason = rejectReason.trim();
// Earlier stages this rejection can be returned to — only stages that have
// already approved. Empty for the first approver, whose only target is the
// customer.
const returnableSteps = rejectStepRow
? steps.filter(
(s) =>
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
)
: [];
const sendBack = rejectTarget !== "CUSTOMER";
const targetStep = sendBack
? returnableSteps.find((s) => s.id === rejectTarget)
: undefined;
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{ stepId: rejectStepRow.id, reason: trimmedReason },
{
stepId: rejectStepRow.id,
reason: trimmedReason,
returnToStepId: sendBack ? rejectTarget : undefined,
},
{ onSuccess: () => closeReject() },
);
};
@@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isNext={actionable && nextPending?.id === step.id}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -192,21 +223,56 @@ export function ContractApprovalStepsCard({
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
{returnableSteps.length > 0 && (
<Select
label="Send rejection to"
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
allowDeselect={false}
value={rejectTarget}
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
data={[
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
})),
]}
/>
)}
{sendBack ? (
<Text size="sm" c="dimmed">
Contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
notified.
</Text>
) : (
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must resubmit this cannot be undone.
</Text>
)}
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
description={
sendBack
? "Shared with the approval chain (not the customer)."
: "Shared with the customer and the approval chain."
}
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
@@ -219,14 +285,16 @@ export function ContractApprovalStepsCard({
Cancel
</Button>
<Button
color="red"
color={sendBack ? "orange" : "red"}
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
: "Reject contract"}
</Button>
</Group>
</Stack>

View File

@@ -46,10 +46,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
// Only serviceable locomotives standing in the selected yard, and not already
// coupled to another built train, can be picked. A new train owns none yet, so
// no train to keep-exclude.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
},
},
enabled: Boolean(yardId),
}),
);

View File

@@ -26,9 +26,19 @@ export default function ChangeLocomotivesModal({
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
// A locomotive already coupled to ANOTHER built train is not a valid pick —
// the API rejects it on save. Exclude those here (keeping this train's own
// ones, which are re-listed below as "(coupled)").
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
excludeTrainId: composition?.id,
},
},
enabled: opened && Boolean(yardId),
}),
);

View File

@@ -28,6 +28,34 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Statuses that block a deactivated train from reactivating (mirrors the API gate). */
export const UNFIT_LOCOMOTIVE_STATUSES = new Set(["MAINTENANCE", "OUT_OF_SERVICE", "UNAVAILABLE"]);
/** Badge color per locomotive status (Mantine palette keys). */
export const locomotiveStatusColor = (status: string): string => {
switch (status) {
case "AVAILABLE":
case "IMPORT_READY":
case "EXPORT_READY":
return "edr-green";
case "ASSIGNED":
return "blue";
case "MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
case "UNAVAILABLE":
return "red";
default:
return "gray";
}
};
export const locomotiveStatusLabel = (status: string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";

View File

@@ -427,6 +427,9 @@ export const URL_CONSTANTS = {
YARDS: "/yards",
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",
SHIPPING_LINE_BY_ID: (id: string) => `/shipping-lines/${id}`,

View File

@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,16 +8,9 @@ import {
type BookingListFilter,
} from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
const parseApiError = extractErrorMessage;
export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({
@@ -55,21 +47,21 @@ export function useBookingMutations(bookingId: string) {
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to accept booking")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
api.bookings.requestChanges.call({ id: bookingId, note }),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(parseApiError(error, "Failed to request changes")),
});
const staffReject = useMutation({
mutationFn: (reason: string) =>
api.bookings.staffReject.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking rejected"),
onError: () => toast.error("Failed to reject booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const reviewOperation = useMutation({
@@ -90,7 +82,7 @@ export function useBookingMutations(bookingId: string) {
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to generate contract")),
});
const signContract = useMutation({
@@ -101,32 +93,32 @@ export function useBookingMutations(bookingId: string) {
consentText?: string;
}) => bookingsService.signContract(bookingId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to sign contract")),
});
const payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete payment")),
});
const startTransit = useMutation({
mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Marked in transit"),
onError: () => toast.error("Failed to start transit"),
onError: (error) => toast.error(parseApiError(error, "Failed to start transit")),
});
const complete = useMutation({
mutationFn: () => api.bookings.complete.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Booking completed"),
onError: () => toast.error("Failed to complete booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete booking")),
});
const cancel = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking cancelled"),
onError: () => toast.error("Failed to cancel booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
const isPending =

View File

@@ -9,6 +9,7 @@ import {
type ContractListFilter,
type SignContractPayload,
} from "@/services/contracts.service";
import { extractErrorMessage } from "@/utils/errorExtractor";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
return Promise.all([
@@ -144,7 +145,7 @@ export function useContractMutations(contractId: string) {
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")),
});
// Edit THIS contract's document articles (per-contract; never the templates).
@@ -152,20 +153,20 @@ export function useContractMutations(contractId: string) {
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
contractsService.updateContractDocument(contractId, snapshot),
onSuccess: (data) => onSuccess(data, "Contract document updated"),
onError: () => toast.error("Failed to update contract document"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to update contract document")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to request changes")),
});
const reject = useMutation({
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
const approveStep = useMutation({
@@ -182,30 +183,46 @@ export function useContractMutations(contractId: string) {
: "Approval step completed";
onSuccess(data, message);
},
onError: () => toast.error("Failed to approve step"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to approve step")),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
// Per-step rejection by an approver (line staff / director / CEO). Two
// flavours: without returnToStepId it is terminal (REJECTED, customer must
// resubmit); with it the contract is sent back to that earlier approver and
// the chain re-runs from there.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
mutationFn: ({
stepId,
reason,
returnToStepId,
}: {
stepId: string;
reason: string;
returnToStepId?: string;
}) =>
contractsService.rejectStep({ id: contractId, stepId, reason, returnToStepId }),
onSuccess: (data, variables) =>
onSuccess(
data,
variables.returnToStepId
? "Contract sent back in the approval chain"
: "Contract rejected",
),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject step")),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to generate contract")),
});
const signContract = useMutation({
mutationFn: (payload: SignContractPayload) =>
contractsService.signContract(contractId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to sign contract")),
});
const createBooking = useMutation({
@@ -331,7 +348,7 @@ export function useContractClearanceMutations(
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Could not approve all documents")),
});
const uploadOutputDocuments = useMutation({
@@ -341,7 +358,7 @@ export function useContractClearanceMutations(
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.error("Upload failed"),
onError: (error) => toast.error(extractErrorMessage(error, "Upload failed")),
});
const finalizeClearance = useMutation({
@@ -380,7 +397,7 @@ export function useCompleteMilestone(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
},
onError: () => toast.error("Failed to complete milestone"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to complete milestone")),
});
}
@@ -402,7 +419,7 @@ export function useAssignRisk(bookingId: string) {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign risk")),
});
}
@@ -420,7 +437,7 @@ export function useAdviseDuty(bookingId: string) {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to advise duty & tax")),
});
}
@@ -436,7 +453,7 @@ export function useAssignStation(bookingId: string) {
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign station")),
});
}
@@ -455,7 +472,7 @@ export function useUploadGlDocuments(bookingId: string) {
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to upload documents")),
});
}
@@ -483,6 +500,6 @@ export function useReportIncident(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to report incident")),
});
}

View File

@@ -21,6 +21,7 @@ import {
invalidateRuleEngineList,
patchRuleEngineListRecord,
} from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
@@ -58,7 +59,7 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
await invalidateRuleEngineList(qc, resource);
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
},
onError: () => toast.error("Failed to update order"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to update order")),
});
const moveOrder = useMutation({
@@ -273,7 +274,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, created);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to create record")),
});
const update = useMutation({
@@ -289,7 +291,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, updated);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to update record")),
});
const remove = useMutation({
@@ -299,7 +302,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
toast.success("Deleted successfully");
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to delete record")),
});
return { create, update, remove };
@@ -455,7 +459,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to submit rate")),
});
const approve = useMutation({
@@ -465,7 +469,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to approve rate")),
});
return { submit, approve };

View File

@@ -23,6 +23,9 @@ export const queryClient = new QueryClient({
void queryClient.invalidateQueries({ queryKey });
}
},
// Mutation failures are surfaced globally by the axios interceptor in
// auth/http.ts (server-message toast on every non-401 failure), so no
// onError toast here — it would double up.
}),
defaultOptions: {
queries: {

View File

@@ -1,6 +1,7 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Box as BoxIcon,
@@ -22,6 +23,7 @@ import {
Users,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -264,7 +266,8 @@ export default function ContractRequestDetailPage() {
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
@@ -428,6 +431,32 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={18} />}
title="Rejection reason"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestRejectionNote}
</Text>
</Alert>
) : null}
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Sent back in the approval chain"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestSendBackNote}
</Text>
</Alert>
) : null}
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}

View File

@@ -19,7 +19,6 @@ import {
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -47,7 +47,7 @@ import {
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string; distanceKm: string };
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
@@ -56,12 +56,12 @@ type RouteFormState = {
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [
{ yardId: "", distanceKm: "0" },
{ yardId: "", distanceKm: "" },
],
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
@@ -163,6 +163,15 @@ export default function RoutesPage() {
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
@@ -206,15 +215,33 @@ export default function RoutesPage() {
[yardsQuery.data],
);
const formTotalKm = useMemo(
() =>
form.milestones.reduce(
(sum, row, index) =>
index === 0 ? sum : sum + Number(row.distanceKm || 0),
0,
),
[form.milestones],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
@@ -234,10 +261,7 @@ export default function RoutesPage() {
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m, index) => ({
yardId: m.yardId,
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
})),
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
@@ -254,7 +278,7 @@ export default function RoutesPage() {
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
milestones: [...current.milestones, { yardId: "" }],
}));
};
@@ -267,11 +291,7 @@ export default function RoutesPage() {
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row, index) => ({
yardId: row.yardId,
distanceKm:
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
})),
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
@@ -284,12 +304,23 @@ export default function RoutesPage() {
});
return;
}
for (let i = 1; i < form.milestones.length; i++) {
const km = Number(form.milestones[i].distanceKm);
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `Enter segment KM for stop ${i + 1}`,
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
@@ -580,8 +611,11 @@ export default function RoutesPage() {
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
@@ -594,16 +628,25 @@ export default function RoutesPage() {
searchable
/>
{index > 0 ? (
<NumberInput
w={120}
label="KM"
min={0}
decimalScale={2}
value={row.distanceKm ? Number(row.distanceKm) : ""}
onChange={(value) =>
setMilestone(index, { distanceKm: String(value ?? "") })
}
/>
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
@@ -619,7 +662,8 @@ export default function RoutesPage() {
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong>
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>

View File

@@ -244,7 +244,12 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
config?.formFields.some(
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
@@ -347,6 +352,19 @@ const RuleEngineResourcePage = () => {
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
// Yard-distance endpoints have no country restriction — any yard can pair
// with any other; the other end is just excluded so A↔A can't be entered.
if (field.name === "fromYardId" || field.name === "toYardId") {
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
(yardOptions ?? [])
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
.map(({ label, value }) => ({ label, value })),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
@@ -604,7 +622,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
canManage && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>

View File

@@ -348,6 +348,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
@@ -370,6 +371,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
category: "configuration",
subtitle: "Rail distance between yard pairs — routes read their segment km from here",
searchPlaceholder: "Search by yard name or code...",
supportsSearch: true,
cardTitleKey: "fromYardLabel",
cardSubtitleKey: "toYardLabel",
columns: [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
{ name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" },
{ name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" },
{
name: "distanceKm",
label: "Distance (km)",
type: "number",
required: true,
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
],
},
{
slug: "priority-configs",
label: "Priority Rules",

View File

@@ -36,8 +36,11 @@ import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import {
directionColor,
locomotiveStatusColor,
locomotiveStatusLabel,
trainStatusColor,
trainStatusLabel,
UNFIT_LOCOMOTIVE_STATUSES,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
@@ -131,6 +134,9 @@ export default function TrainBuilderDetailPage() {
const { totals } = composition;
const yard = composition.currentYard;
const blockingLocomotives = composition.locomotives.filter((loco) =>
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
);
return (
<PageContainer>
@@ -180,6 +186,7 @@ export default function TrainBuilderDetailPage() {
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
@@ -234,6 +241,59 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
<Stack gap="xs">
<Text size="sm">
Cannot reactivate {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
not fit for service:{" "}
{blockingLocomotives.map((loco, i) => (
<span key={loco.id}>
{i > 0 ? ", " : ""}
<Text span fw={600} ff="monospace">
{loco.code}
</Text>{" "}
({locomotiveStatusLabel(loco.status)})
</span>
))}
.
</Text>
<Group gap="xs">
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
<Button
size="compact-sm"
variant="subtle"
onClick={() => navigate(`/dashboard/locomotives`)}
>
Go to locomotives
</Button>
</Group>
</Stack>
</Alert>
) : null}
<Group gap="xs">
{composition.locomotives.map((loco) => (
<Badge
key={loco.id}
variant="light"
color={locomotiveStatusColor(loco.status)}
leftSection={<TrainFront size={12} />}
>
{loco.code} · {locomotiveStatusLabel(loco.status)}
</Badge>
))}
</Group>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({

View File

@@ -229,15 +229,26 @@ export const contractsService = {
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
/**
* Reject the current step. Without `returnToStepId` the contract is rejected
* to the customer (terminal). With it, the contract is sent back to that
* earlier approved step and the chain re-runs from there.
*/
rejectStep: ({
id,
stepId,
reason,
returnToStepId,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
returnToStepId?: string;
}) =>
postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), {
reason,
...(returnToStepId ? { returnToStepId } : {}),
}),
// ── Contract document ──
generateContract: (id: string) =>

View File

@@ -15,6 +15,10 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
currentYardId?: string;
/** Drop locos already coupled to a built train (train-builder picker). */
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
}
export interface Locomotive {
@@ -48,6 +52,8 @@ export const locomotivesService = {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -35,8 +35,9 @@ export interface RouteRecord {
milestones?: RouteMilestone[];
}
/** Segment km are resolved server-side from configured yard distances. */
export interface SaveRoutePayload {
milestones: Array<{ yardId: string; distanceKm?: number }>;
milestones: Array<{ yardId: string }>;
status?: RouteStatus;
}

View File

@@ -86,6 +86,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
@@ -107,6 +108,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
case "yards":
return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id);
case "yard-distances":
return URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCE_BY_ID(id);
case "shipping-lines":
return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id);
case "rates":

View File

@@ -6,6 +6,7 @@ export type RuleEngineResourceSlug =
| "service-types"
| "weight-limit-rules"
| "yards"
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";

View File

@@ -0,0 +1,17 @@
/**
* Pull the SERVER's actual error message out of a failed request.
*
* NestJS returns `{ message: string | string[] }`; a class-validator failure is
* the array form (joined here). Falls back to the error's own `.message` — the
* axios response interceptor (see `auth/http.ts`) already rewrites that to the
* server message, so even code paths that never see the raw response body get
* the real cause — then to the caller's fallback string.
*/
export const extractErrorMessage = (err: unknown, fallback: string): string => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(msg)) return msg.filter(Boolean).join(", ");
if (typeof msg === "string" && msg) return msg;
if (err instanceof Error && err.message) return err.message;
return fallback;
};

View File

@@ -409,6 +409,10 @@ export default function ContractDetailPage() {
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
// Intercity (DOMESTIC) has no customs — the document gate collects the
// admin-configured intercity set, reviewed by Operations.
const isIntercity = contract.tradeDirection === "DOMESTIC";
const docNoun = isIntercity ? "intercity documents" : "clearance documents";
// Only the NON-customs (Path A) customer books himself — once the contract is
// executed after self-clearance. Customs (Path B) bookings are created by
// Global Logistics on the customer's behalf, so the customer gets no booking
@@ -586,8 +590,8 @@ export default function ContractDetailPage() {
onClick={clearanceModal.open}
>
{contract.status === "CLEARANCE_UNDER_REVIEW"
? "Manage clearance documents"
: "Upload clearance documents"}
? `Manage ${docNoun}`
: `Upload ${docNoun}`}
</Button>
)}
</Group>
@@ -852,7 +856,9 @@ export default function ContractDetailPage() {
<Text fw={700} fz={15} c={INK}>
{customsPath
? "Customs clearance shipment"
: "Customs clearance required"}
: isIntercity
? "Intercity documents required"
: "Customs clearance required"}
</Text>
</Group>
<Text fz={13} c="dimmed">
@@ -862,11 +868,17 @@ export default function ContractDetailPage() {
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
: isIntercity
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload the required intercity documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your intercity documents. Re-upload any queried documents to proceed."
: "Your intercity documents are approved. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
<Button
@@ -1581,7 +1593,7 @@ export default function ContractDetailPage() {
onClose={clearanceModal.close}
title={
<Text fw={700} fz={16}>
Clearance documents
{isIntercity ? "Intercity documents" : "Clearance documents"}
</Text>
}
size="xl"

View File

@@ -254,6 +254,14 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
</Text>
</Group>
)}
{contract.status === "REJECTED" && contract.latestRejectionNote && (
<Text
fz={12.5}
style={{ color: "#B42318", whiteSpace: "pre-wrap", width: "100%" }}
>
Reason: {contract.latestRejectionNote}
</Text>
)}
{expirySoon && (
<Group gap={6} wrap="nowrap" align="center">
<AlertTriangle size={14} color="#9A6700" />

View File

@@ -480,13 +480,6 @@ export default function NewContractPage({
? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget)
: "";
const onboardingDocs = useMemo(() => {
const profiles = auth.company?.company?.companyProfiles ?? [];
const active =
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
return active?.licenseFiles ?? [];
}, [auth.company, auth.activeCompanyProfileId]);
async function handleContinue() {
const fields = contractStepFields[step];
if (fields.length > 0) {
@@ -580,12 +573,15 @@ export default function NewContractPage({
lastMileDeliveryLng: data.lastMile.lng ?? undefined,
}
: {}),
...(serviceType?.includesCustoms && data.customsClearingEnabled
? {
customsClearingEnabled: true,
customsClearingAgent: data.customsClearingAgent || undefined,
}
: { customsClearingEnabled: false }),
// Customs bundling is a property of the chosen service, not of a stored
// form flag — derive it here so stale drafts can't misreport it. A
// non-bundled contract still records the customer's own clearing agent.
...(serviceType?.includesCustoms
? { customsClearingEnabled: true }
: {
customsClearingEnabled: false,
customsClearingAgent: data.customsClearingAgent?.trim() || undefined,
}),
cargoScope,
routes,
};
@@ -787,7 +783,6 @@ export default function NewContractPage({
setStep={setStep}
direction={direction!}
referenceData={referenceData}
onboardingDocs={onboardingDocs}
pricing={
pricingData
? {

View File

@@ -289,16 +289,22 @@ export function Step2ServiceType({
);
}
// Customs clearance bundling is driven by the service: includesCustoms → the
// contract follows Path B (clearance docs after sign); otherwise Path A.
if (includesCustoms) {
form.setValue("customsClearingEnabled", true, { shouldDirty: true });
form.setValue("customsClearingAgent", "", { shouldDirty: true });
} else {
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
}, [serviceType, includesFirstMile, includesLastMile, form]);
// Customs clearance bundling is driven by the service: includesCustoms → the
// contract follows Path B (clearance docs after sign); otherwise Path A.
// Synced unconditionally — the prev-service guard above skips the very first
// selection and restored drafts, which left customsClearingEnabled stale.
useEffect(() => {
const desired = Boolean(includesCustoms);
if (form.getValues("customsClearingEnabled") !== desired) {
form.setValue("customsClearingEnabled", desired, { shouldDirty: true });
}
// A bundled-customs service never carries a customer-named agent.
if (desired && form.getValues("customsClearingAgent")) {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [serviceType, includesFirstMile, includesLastMile, includesCustoms, form]);
}, [includesCustoms, form]);
// A hidden mile must not leak a stale enabled=true into the payload. The
// effect above only fires on service change; switching operation type (import

View File

@@ -17,6 +17,7 @@ import {
FileText,
MapPin,
Package,
RotateCcw,
Route,
Send,
Truck,
@@ -180,7 +181,6 @@ export function Step8Review({
form,
direction,
referenceData,
onboardingDocs = [],
pricing,
onSaveDraft,
onSubmit,
@@ -194,7 +194,6 @@ export function Step8Review({
setStep?: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
onboardingDocs?: Array<{ name: string; size?: number }>;
/** Unit-rate quotation, if the customer has already generated price. */
pricing?: { currency: string; lineItems: Freight.ContractUnitRateLineItem[] } | null;
onSaveDraft?: () => void;
@@ -216,15 +215,46 @@ export function Step8Review({
);
const isGeneralContract = values.contractKind === "general_contract";
const isIntercity = values.operationType === "intercity";
const attachedDocs = Object.entries(
(values.documents ?? {}) as Record<string, File | File[] | null>,
)
.filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v)))
.map(([key, v]) => ({
name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key),
}));
const onboardingDocsCount = attachedDocs.length || onboardingDocs.length;
// Customs is a property of the chosen service (bundled → Global Logistics),
// not of the stored form flag — a stale draft flag must not misreport it.
// Without bundling, the customer may still name their own clearing agent.
const ownAgent = values.customsClearingAgent?.trim();
const customsValue = isIntercity
? "Not applicable — domestic transport"
: serviceType?.includesCustoms || values.customsClearingEnabled
? "Included — Global Logistics"
: ownAgent
? `Own agent — ${ownAgent}`
: "Not requested";
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.
const firstMileValue =
direction === "IMPORT"
? "Not applicable for import"
: serviceType && !serviceType.includesFirstMile
? "Not included in service"
: values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: "Not requested";
const lastMileValue =
direction === "EXPORT"
? "Not applicable for export"
: serviceType && !serviceType.includesLastMile
? "Not included in service"
: values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: "Not requested";
const cargoValue = (() => {
if (values.cargoType === "container") {
@@ -372,39 +402,17 @@ export function Step8Review({
<SummaryItem
icon={<Truck size={18} />}
label="First mile — pick-up"
value={
values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: "Not requested"
}
value={firstMileValue}
/>
<SummaryItem
icon={<Truck size={18} />}
label="Last mile — delivery"
value={
values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: "Not requested"
}
value={lastMileValue}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Customs clearing"
value={
values.customsClearingEnabled
? values.customsClearingAgent
? `Agent: ${values.customsClearingAgent}`
: "Global Logistics"
: "Not requested"
}
value={customsValue}
/>
<SummaryItem
icon={<Package size={18} />}
@@ -416,15 +424,17 @@ export function Step8Review({
label="Refrigerated"
value={values.isRefrigerated ? "Yes" : "No"}
/>
<SummaryItem
icon={<FileText size={18} />}
label="Documents"
value={
onboardingDocsCount > 0
? `${onboardingDocsCount} attached`
: "None attached"
}
/>
{values.cargoType === "container" && (
<SummaryItem
icon={<RotateCcw size={18} />}
label="Empty-container return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
)}
</Box>
</Paper>
@@ -489,10 +499,6 @@ export function Step8Review({
}
label="Cargo scope complete"
/>
<ReadinessItem
done={onboardingDocsCount > 0}
label="Documents attached"
/>
</Stack>
</Paper>

View File

@@ -674,6 +674,18 @@ export interface IContract extends BaseEntity {
* exactly what to fix before resubmitting.
*/
latestChangeRequestNote?: string | null;
/**
* Body of the latest REJECTION review note (detail response only, when
* status is REJECTED). Shows staff and customer why the contract was
* rejected.
*/
latestRejectionNote?: string | null;
/**
* Body of the latest send-back STAFF_NOTE (detail response only, while the
* contract is PENDING_APPROVAL and no step has acted since the send-back).
* Tells the returned-to approver why the chain came back to them.
*/
latestSendBackNote?: string | null;
clearanceStatus: ContractClearanceStatus;
clearanceCycleNumber: number;
/**