mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: implement wagon transfer management modals and page
- Add TransferFulfillModal for fulfilling wagon transfer requests. - Create TransferRequestFormModal for filing new wagon transfer requests. - Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled. - Develop WagonTransfersPage to manage and display wagon transfer requests. - Implement utility functions for handling wagon transfer request data and UI components. - Enhance UI with Mantine components for better user experience.
This commit is contained in:
@@ -81,6 +81,32 @@ export const WagonTransferFulfill = () =>
|
||||
export const WagonTransferHistoryAll = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
/**
|
||||
* Open the transfer-requests desk. `wagons:view` is accepted as a one-of
|
||||
* fallback so staff who could already reach the queue keep it without a
|
||||
* re-grant — same pattern the granular fleet keys use.
|
||||
*/
|
||||
export const WagonTransferView = () =>
|
||||
BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
|
||||
|
||||
/** Withdraw a request that has not moved any wagon yet. */
|
||||
export const WagonTransferCancel = () =>
|
||||
BookingStaff([
|
||||
FREIGHT_PERMS.wagons.transferCancel,
|
||||
FREIGHT_PERMS.wagons.transferRequest,
|
||||
]);
|
||||
|
||||
/**
|
||||
* End a request short of the requested count. Whoever may move wagons may also
|
||||
* declare the yard has no more to give, so fulfil is accepted alongside the
|
||||
* dedicated key.
|
||||
*/
|
||||
export const WagonTransferCloseShort = () =>
|
||||
BookingStaff([
|
||||
FREIGHT_PERMS.wagons.transferCloseShort,
|
||||
FREIGHT_PERMS.wagons.transferFulfill,
|
||||
]);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial wagon-transfer fulfilment.
|
||||
*
|
||||
* A request for 50 wagons no longer has to be met in one go: OCC moves what the
|
||||
* source yard can spare, whenever it can, and the request stays open until the
|
||||
* full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the
|
||||
* requester can ask another yard for the rest.
|
||||
*
|
||||
* Existing rows are back-filled so history keeps reading correctly: a FULFILLED
|
||||
* request delivered its whole quantity; anything else delivered nothing.
|
||||
*/
|
||||
export class AddWagonTransferPartialFulfilment2930000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_transfer_requests
|
||||
SET fulfilled_quantity = quantity
|
||||
WHERE status = 'FULFILLED'
|
||||
AND fulfilled_quantity = 0
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
DROP COLUMN IF EXISTS fulfilled_quantity,
|
||||
DROP COLUMN IF EXISTS closed_short_at,
|
||||
DROP COLUMN IF EXISTS closed_short_by_user_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
@@ -1111,9 +1111,11 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
// Anything other than approval has no document gate and no concurrency
|
||||
// hazard — apply it directly.
|
||||
// hazard — no row lock, just the write.
|
||||
if (status !== ProfileStatus.Active) {
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
return this.dataSource.transaction((manager) =>
|
||||
this.applyProfileStatus(manager, existing, status, note, reviewerId),
|
||||
);
|
||||
}
|
||||
|
||||
// Approving over an outstanding document correction would silently accept the
|
||||
@@ -1149,7 +1151,7 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1160,11 +1162,21 @@ export class CompaniesService {
|
||||
* transaction while every other status skips that overhead.
|
||||
*/
|
||||
private async applyProfileStatus(
|
||||
manager: EntityManager,
|
||||
existing: CompanyProfile,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
// Every write below goes through `manager`. The approval path holds a
|
||||
// pessimistic_write lock on the company row, and the injected repositories
|
||||
// are bound to the DataSource's default pool — writing the same row through
|
||||
// one of them would block on a lock this very transaction holds, hanging the
|
||||
// request until the statement timed out. That deadlocked the first approval
|
||||
// of any customer: the profile went Active on its own connection while the
|
||||
// company stayed Pending and the caller never got a response.
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1190,7 +1202,8 @@ export class CompaniesService {
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
||||
await profileRepo.update(existing.id, patch);
|
||||
const updated = await profileRepo.findOne({ where: { id: existing.id } });
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
@@ -1208,7 +1221,9 @@ export class CompaniesService {
|
||||
: "approved"
|
||||
: null;
|
||||
if (change) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
const company = await companyRepo.findOne({
|
||||
where: { id: updated.companyId },
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
company,
|
||||
@@ -1222,7 +1237,7 @@ export class CompaniesService {
|
||||
status === ProfileStatus.Active &&
|
||||
company.status === CompanyStatus.Pending
|
||||
) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
|
||||
@@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
// A ONE_TIME contract allows a single booking, so once that booking
|
||||
// exists the contract is spent and can never carry another shipment.
|
||||
// Without this it kept blocking new requests on the same service type +
|
||||
// route until its validity lapsed — locking a customer out of a lane for
|
||||
// the rest of the term after one completed shipment.
|
||||
.andWhere(
|
||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||
))`,
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
|
||||
// Built train: A-B-C-D coupled in that order. Slots are created by the wagon
|
||||
// PLAN, so their sequenceNo says nothing about where the wagon actually sits.
|
||||
const TRAIN = ['A', 'B', 'C', 'D'];
|
||||
|
||||
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
|
||||
sequenceNo,
|
||||
physicalWagonId,
|
||||
});
|
||||
|
||||
describe('orderConsistWagons', () => {
|
||||
it('draws slots in the train coupling order, not slot order', () => {
|
||||
// Plan order says D then B; the train says B sits ahead of D.
|
||||
const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']);
|
||||
expect(drawn.map((w) => w.position)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('interleaves empty consist wagons in their real place', () => {
|
||||
// Loaded slots on A and C; B and D ride along empty. The empties used to be
|
||||
// appended after every loaded slot, so the drawing was never the train.
|
||||
const drawn = orderConsistWagons(
|
||||
[slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')],
|
||||
{ physicalWagonIdsInOrder: TRAIN },
|
||||
);
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('keeps every wagon in place when a load moves between wagons', () => {
|
||||
// Load sat on A (slot 1); staff drag it onto empty D. The move repins the
|
||||
// slot, so the SAME slot now reads as wagon D and A falls back to empty.
|
||||
const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
// A is drawn first and D last, before and after — the train did not shuffle.
|
||||
expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||
expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||
});
|
||||
|
||||
it('follows a train-builder reorder without touching any slot row', () => {
|
||||
const slots = [slot(1, 'A'), slot(2, 'B')];
|
||||
|
||||
// Builder swaps the coupling order; the slots are untouched.
|
||||
const drawn = orderConsistWagons(slots, {
|
||||
physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'],
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('draws back-to-front when the caller reverses the train', () => {
|
||||
const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], {
|
||||
physicalWagonIdsInOrder: [...TRAIN].reverse(),
|
||||
reverseWagonOrder: true,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']);
|
||||
});
|
||||
|
||||
it('parks unpinned slots last, in slot order', () => {
|
||||
const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([
|
||||
['C', 1],
|
||||
[null, 4],
|
||||
[null, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to slot order when there is no built train', () => {
|
||||
// Frozen schedules and loose-wagon schedules pass no physical order.
|
||||
const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], {
|
||||
physicalWagonIdsInOrder: [],
|
||||
});
|
||||
expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]);
|
||||
|
||||
const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], {
|
||||
physicalWagonIdsInOrder: [],
|
||||
reverseWagonOrder: true,
|
||||
});
|
||||
expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Draw order for a schedule's consist.
|
||||
*
|
||||
* A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in
|
||||
* the train. The train's real coupling order lives on the physical wagons
|
||||
* (`wagons.sequence_number`), which the caller passes in already ordered — ASC
|
||||
* normally, DESC for a `reverseWagonOrder` schedule.
|
||||
*
|
||||
* Ordering by the physical wagon is what keeps the drawing honest:
|
||||
* - moving a load between wagons repaints WHICH wagon is loaded and never
|
||||
* shuffles the train, because each slot is drawn wherever its wagon sits;
|
||||
* - a train-builder reorder lands on the next read, allocations included,
|
||||
* since the order is derived on every read instead of copied at pin time.
|
||||
*
|
||||
* Slots with no physical wagon (not pinned yet, or a schedule that isn't tied
|
||||
* to a built train) have no place in the consist — they keep slot order, last.
|
||||
*/
|
||||
export interface ConsistOrderable {
|
||||
sequenceNo: number;
|
||||
physicalWagonId?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsistOrderOptions {
|
||||
/**
|
||||
* Every wagon coupled to the built train, in real coupling order (already
|
||||
* reversed by the caller for a `reverseWagonOrder` schedule). Empty for a
|
||||
* frozen schedule or one with no built train — the consist then keeps slot
|
||||
* order.
|
||||
*/
|
||||
physicalWagonIdsInOrder: string[];
|
||||
reverseWagonOrder?: boolean;
|
||||
}
|
||||
|
||||
export const orderConsistWagons = <T extends ConsistOrderable>(
|
||||
wagons: T[],
|
||||
{ physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions,
|
||||
): (T & { position: number })[] => {
|
||||
const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index]));
|
||||
const bySlotSequence = (a: T, b: T) =>
|
||||
reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo;
|
||||
|
||||
const ordered = physicalOrder.size
|
||||
? [...wagons].sort((a, b) => {
|
||||
const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined;
|
||||
const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined;
|
||||
if (ai == null && bi == null) return bySlotSequence(a, b);
|
||||
if (ai == null) return 1;
|
||||
if (bi == null) return -1;
|
||||
return ai - bi;
|
||||
})
|
||||
: [...wagons].sort(bySlotSequence);
|
||||
|
||||
return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 }));
|
||||
};
|
||||
@@ -147,6 +147,7 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
} from './booking-batch.constants';
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
@@ -6696,6 +6697,18 @@ export class TrainSchedulingService {
|
||||
consistOnly: true,
|
||||
}));
|
||||
|
||||
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
|
||||
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
|
||||
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
|
||||
// the slot's own stored value.
|
||||
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
|
||||
list: T[],
|
||||
) =>
|
||||
orderConsistWagons(list, {
|
||||
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
|
||||
reverseWagonOrder: schedule.reverseWagonOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6785,7 +6798,8 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: (schedule.trainSet.wagons ?? [])
|
||||
wagons: drawConsist(
|
||||
(schedule.trainSet.wagons ?? [])
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
@@ -6875,12 +6889,8 @@ export class TrainSchedulingService {
|
||||
})) ?? [],
|
||||
};
|
||||
})
|
||||
.concat(emptyConsistWagons)
|
||||
.sort((a, b) =>
|
||||
schedule.reverseWagonOrder
|
||||
? b.sequenceNo - a.sequenceNo
|
||||
: a.sequenceNo - b.sequenceNo,
|
||||
),
|
||||
.concat(emptyConsistWagons),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
@@ -7458,8 +7468,12 @@ export class TrainSchedulingService {
|
||||
];
|
||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
// Name wagons by their physical number — the consist is drawn in the train's
|
||||
// coupling order, so a slot's sequenceNo is not the position staff can see.
|
||||
const slotLabel = (slot: TrainSetWagon) =>
|
||||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
@@ -7501,7 +7515,7 @@ export class TrainSchedulingService {
|
||||
if (targetAllocs.length) {
|
||||
checkReceives(
|
||||
targetAllocs,
|
||||
`#${source.sequenceNo}`,
|
||||
slotLabel(source),
|
||||
source.wagonType,
|
||||
Number(source.capacityTons),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC ends a transfer request with fewer wagons than asked for. The note is
|
||||
* carried into the requester's notification — it is what tells them WHY the
|
||||
* yard could not give the rest.
|
||||
*/
|
||||
export class CloseShortTransferRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Why the source yard cannot supply the remainder',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const;
|
||||
|
||||
/**
|
||||
* Transfer-desk list query. `status` accepts a comma-separated list so the
|
||||
* "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call.
|
||||
*/
|
||||
export class ListTransferRequestsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'One status or a comma-separated list',
|
||||
enum: WagonTransferRequestStatus,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
fromYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Destination yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
toYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Wagon type' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn([...SORT_FIELDS])
|
||||
sortBy?: (typeof SORT_FIELDS)[number];
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* How many have actually moved so far. OCC sends what the yard can spare,
|
||||
* whenever it can — the request stays open until this reaches `quantity` or
|
||||
* OCC closes it short.
|
||||
*/
|
||||
@Column({ name: 'fulfilled_quantity', type: 'int', default: 0 })
|
||||
fulfilledQuantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
@@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
/** When the LAST transfer against this request ran (not necessarily the full count). */
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
/** Set when OCC ended the request with fewer wagons than asked for. */
|
||||
@Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true })
|
||||
closedShortAt?: Date | null;
|
||||
|
||||
@Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true })
|
||||
closedShortByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferCancel,
|
||||
WagonTransferCloseShort,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
WagonTransferView,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/** Query-string number, or undefined when absent/garbage (service defaults it). */
|
||||
const toInt = (value?: string): number | undefined => {
|
||||
const n = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
* The wagon-transfer desk. A requester (transfer_request) files a count-only
|
||||
* request; OCC (transfer_fulfill) moves wagons against it in as many
|
||||
* instalments as the source yard allows, and closes it short
|
||||
* (transfer_close_short) when the yard has no more to give. Separate top-level
|
||||
* path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@WagonTransferView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@@ -47,10 +56,12 @@ export class WagonTransferRequestsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
|
||||
})
|
||||
list(@Query() query: ListTransferRequestsQueryDto) {
|
||||
return this.service.listRequests(query);
|
||||
}
|
||||
|
||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||
@@ -73,24 +84,48 @@ export class WagonTransferRequestsController {
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
myHistory(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
if (!user?.id) {
|
||||
return {
|
||||
requests: [],
|
||||
movements: [],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
requestsTotal: 0,
|
||||
movementsTotal: 0,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
allHistory(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -110,9 +145,26 @@ export class WagonTransferRequestsController {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/close-short')
|
||||
@WagonTransferCloseShort()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
|
||||
})
|
||||
closeShort(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CloseShortTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.closeShort(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
@WagonTransferCancel()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
|
||||
})
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ConflictException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Instalment fulfilment: a request for 50 wagons is met with whatever the source
|
||||
* yard can spare, whenever it can spare it. It stays open until the full count
|
||||
* lands or OCC closes it short — which is what tells the requester to go ask
|
||||
* another yard.
|
||||
*/
|
||||
describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
|
||||
({
|
||||
id: 'req-1',
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
fulfilledQuantity: 0,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: 'user-1',
|
||||
...over,
|
||||
}) as WagonTransferRequest;
|
||||
|
||||
let requestRepo: {
|
||||
findOne: jest.Mock;
|
||||
find: jest.Mock;
|
||||
save: jest.Mock;
|
||||
create: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let wagonRepo: { find: jest.Mock; count: jest.Mock };
|
||||
let wagonsService: { bulkTransfer: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: WagonTransferRequestsService;
|
||||
let stored: WagonTransferRequest;
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const build = (row: WagonTransferRequest) => {
|
||||
stored = row;
|
||||
requestRepo.findOne.mockImplementation(async () => stored);
|
||||
requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
|
||||
stored = r;
|
||||
return r;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
requestRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((r) => r),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
|
||||
wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new WagonTransferRequestsService(
|
||||
requestRepo as never,
|
||||
wagonRepo as never,
|
||||
{ find: jest.fn(), findAndCount: jest.fn() } as never,
|
||||
wagonsService as never,
|
||||
inbox as never,
|
||||
);
|
||||
build(request());
|
||||
});
|
||||
|
||||
const availableWagons = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `w-${i}`,
|
||||
wagonNumber: `100${i}`,
|
||||
currentYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
status: 'AVAILABLE',
|
||||
}));
|
||||
|
||||
describe('fulfillRequest', () => {
|
||||
it('books an instalment and keeps the request open', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('completes the request when the last instalment lands', async () => {
|
||||
build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(50);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
|
||||
});
|
||||
|
||||
it('refuses to move more than is still owed', async () => {
|
||||
build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(10));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(10).map((w) => w.id),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to touch a request that is already closed', async () => {
|
||||
build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('tells the requester what landed and what is still owed', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ userIds: ['user-1'] });
|
||||
expect(sent.body).toContain('20 wagon(s) have arrived');
|
||||
expect(sent.body).toContain('30 of 50 still to come');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkFulfill', () => {
|
||||
it('sends what the yard has instead of skipping a short request', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips only when the yard has nothing to give', async () => {
|
||||
wagonRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
expect(result.skipped[0].reason).toContain('No available wagons');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeShort', () => {
|
||||
it('ends the request and tells the requester to ask another yard', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
|
||||
await flush();
|
||||
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
|
||||
expect(stored.closedShortAt).toBeInstanceOf(Date);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.body).toContain('Only 20 of the 50');
|
||||
expect(sent.body).toContain('Yard is empty until Friday');
|
||||
expect(sent.body).toContain('Request the remaining 30');
|
||||
});
|
||||
|
||||
it('refuses when the request is already fully supplied', async () => {
|
||||
build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelRequest', () => {
|
||||
it('withdraws a request that never moved a wagon', async () => {
|
||||
await service.cancelRequest('req-1');
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
|
||||
});
|
||||
|
||||
it('refuses once wagons have moved — close it short instead', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.cancelRequest('req-1')).rejects.toThrow(
|
||||
/close it short/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
it('accepts a count larger than what the yard holds today', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(requestRepo.save).toHaveBeenCalled();
|
||||
expect(stored.quantity).toBe(50);
|
||||
});
|
||||
|
||||
it('still refuses a same-yard move', async () => {
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 5,
|
||||
reason: 'x',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
OPEN_WAGON_TRANSFER_STATUSES,
|
||||
PaginatedResponse,
|
||||
WagonStatus,
|
||||
WagonTransferRequestStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
@@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service';
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
/**
|
||||
* One pager drives both lists (they are shown side by side), so it carries a
|
||||
* total per list and the page count of the longer one.
|
||||
*/
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
requestsTotal: number;
|
||||
movementsTotal: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
/** Hard ceiling on a single history page, whatever the client asks for. */
|
||||
const HISTORY_LIMIT = 100;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
@@ -38,6 +61,8 @@ const REQUEST_RELATIONS = {
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
private readonly logger = new Logger(WagonTransferRequestsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@@ -46,13 +71,14 @@ export class WagonTransferRequestsService {
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
||||
* the source yard: staff may only ask for wagons that are actually there to
|
||||
* give. A reason is mandatory and is shown on the OCC queue.
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, and the
|
||||
* count is NOT capped by what the source yard holds today: OCC fulfils in
|
||||
* instalments, so asking for 50 while only 20 sit there is a normal, useful
|
||||
* request. A reason is mandatory and is shown on the OCC queue.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
@@ -63,14 +89,6 @@ export class WagonTransferRequestsService {
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
||||
if (available < dto.quantity) {
|
||||
throw new BadRequestException(
|
||||
available === 0
|
||||
? 'No available wagons of this type in the source yard'
|
||||
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
@@ -85,8 +103,12 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
/**
|
||||
* AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
|
||||
* right now. Shown on the desk beside the outstanding count so staff see at a
|
||||
* glance how much of a request the yard can cover today.
|
||||
*/
|
||||
countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
return this.wagonRepo.count({
|
||||
where: {
|
||||
currentYardId: yardId,
|
||||
@@ -96,15 +118,51 @@ export class WagonTransferRequestsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
/**
|
||||
* The transfer desk list: paginated, newest first, filterable by status (one
|
||||
* or a comma-separated set — the "Open" tab asks for PENDING +
|
||||
* PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text.
|
||||
*/
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
query: ListTransferRequestsQueryDto,
|
||||
): Promise<PaginatedResponse<WagonTransferRequest>> {
|
||||
const qb = this.requestRepo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.fromYard', 'fromYard')
|
||||
.leftJoinAndSelect('r.toYard', 'toYard')
|
||||
.leftJoinAndSelect('r.wagonType', 'wagonType');
|
||||
|
||||
const statuses = (query.status ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (statuses.length) {
|
||||
qb.andWhere('r.status IN (:...statuses)', { statuses });
|
||||
}
|
||||
if (query.fromYardId) {
|
||||
qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId });
|
||||
}
|
||||
if (query.toYardId) {
|
||||
qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId });
|
||||
}
|
||||
if (query.wagonTypeId) {
|
||||
qb.andWhere('r.wagon_type_id = :wagonTypeId', {
|
||||
wagonTypeId: query.wagonTypeId,
|
||||
});
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` });
|
||||
}
|
||||
|
||||
const sortColumn =
|
||||
query.sortBy === 'quantity'
|
||||
? 'r.quantity'
|
||||
: query.sortBy === 'status'
|
||||
? 'r.status'
|
||||
: 'r.created_at';
|
||||
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
|
||||
|
||||
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
@@ -116,11 +174,23 @@ export class WagonTransferRequestsService {
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Wagons still owed on an open request. */
|
||||
private remainingOn(request: WagonTransferRequest): number {
|
||||
return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0));
|
||||
}
|
||||
|
||||
/** True while OCC can still move wagons against this request. */
|
||||
private isOpen(request: WagonTransferRequest): boolean {
|
||||
return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
||||
* in the request's source yard, match its wagon type, and the count must equal
|
||||
* the requested quantity — then the transfer runs and the request is marked
|
||||
* FULFILLED.
|
||||
* OCC moves hand-picked wagons against an open request. Any number from 1 up
|
||||
* to whatever is still owed — the yard rarely has the whole ask at once, so a
|
||||
* request for 50 can be met 20 now, 30 later. Every wagon must sit in the
|
||||
* source yard, match the type and be available. The request completes on its
|
||||
* own once the full count has moved; short of that it stays open as
|
||||
* PARTIALLY_FULFILLED and the requester is told what landed.
|
||||
*/
|
||||
async fulfillRequest(
|
||||
id: string,
|
||||
@@ -128,16 +198,17 @@ export class WagonTransferRequestsService {
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
const remaining = this.remainingOn(request);
|
||||
if (wagonIds.length > remaining) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
`Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,21 +249,124 @@ export class WagonTransferRequestsService {
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagonIds.length, userId);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC accepts AND executes a subset of pending requests in one action. For
|
||||
* each selected request the system auto-picks the required number of
|
||||
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
||||
* number first) and runs the audited transfer. A request that cannot be
|
||||
* executed — already decided, or not enough available wagons left after the
|
||||
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
||||
* both teams; nothing is rolled back for the others.
|
||||
* Book an instalment against a request: bump the delivered count, complete it
|
||||
* when the full ask has landed, and tell the requester what moved. Shared by
|
||||
* the hand-picked and auto-picked (bulk) fulfilment paths.
|
||||
*/
|
||||
private async recordDelivery(
|
||||
request: WagonTransferRequest,
|
||||
moved: number,
|
||||
userId?: string | null,
|
||||
): Promise<void> {
|
||||
request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved;
|
||||
request.status =
|
||||
request.fulfilledQuantity >= request.quantity
|
||||
? WagonTransferRequestStatus.Fulfilled
|
||||
: WagonTransferRequestStatus.PartiallyFulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
this.notifyRequester(request, moved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the requester what landed. Fire-and-forget: a notification failure must
|
||||
* never undo a transfer that already moved wagons.
|
||||
*/
|
||||
private notifyRequester(
|
||||
request: WagonTransferRequest,
|
||||
moved: number,
|
||||
closedShortNote?: string | null,
|
||||
): void {
|
||||
if (!request.requestedByUserId) return;
|
||||
const outstanding = this.remainingOn(request);
|
||||
const complete = request.status === WagonTransferRequestStatus.Fulfilled;
|
||||
const closedShort =
|
||||
request.status === WagonTransferRequestStatus.ClosedShort;
|
||||
|
||||
const title = complete
|
||||
? `All ${request.quantity} wagon(s) transferred`
|
||||
: closedShort
|
||||
? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)`
|
||||
: `${moved} of ${request.quantity} wagon(s) transferred`;
|
||||
|
||||
const body = complete
|
||||
? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.`
|
||||
: closedShort
|
||||
? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` +
|
||||
`${closedShortNote ? `: ${closedShortNote}` : '.'} ` +
|
||||
`Request the remaining ${outstanding} from another yard.`
|
||||
: `${moved} wagon(s) have arrived against your request. ` +
|
||||
`${outstanding} of ${request.quantity} still to come.`;
|
||||
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { userIds: [request.requestedByUserId] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/wagon-transfers/${request.id}`,
|
||||
data: {
|
||||
transferRequestId: request.id,
|
||||
delivered: request.fulfilledQuantity,
|
||||
requested: request.quantity,
|
||||
outstanding,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`Transfer notification failed for ${request.id}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC ends a request with fewer wagons than asked for — the source yard has
|
||||
* nothing more to give. What already moved stays moved; the requester is told
|
||||
* the shortfall so they can raise it against another yard. Cancelling is for
|
||||
* requests that never moved anything; this is the close for ones that did.
|
||||
*/
|
||||
async closeShort(
|
||||
id: string,
|
||||
dto: CloseShortTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (!this.isOpen(request)) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
);
|
||||
}
|
||||
if (this.remainingOn(request) === 0) {
|
||||
throw new ConflictException(
|
||||
'Nothing outstanding — this request is already fully supplied',
|
||||
);
|
||||
}
|
||||
|
||||
request.status = WagonTransferRequestStatus.ClosedShort;
|
||||
request.closedShortAt = new Date();
|
||||
request.closedShortByUserId = userId ?? null;
|
||||
if (dto.note?.trim()) {
|
||||
request.note = dto.note.trim();
|
||||
}
|
||||
await this.requestRepo.save(request);
|
||||
this.notifyRequester(request, 0, dto.note ?? null);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC executes a set of open requests in one action, auto-picking AVAILABLE
|
||||
* wagons of the requested type from each source yard (lowest wagon number
|
||||
* first). A yard that cannot cover the whole ask still sends what it has —
|
||||
* the request stays open for the rest rather than being skipped, which is the
|
||||
* whole point of instalments. Only a request with NOTHING available is
|
||||
* skipped, and nothing is rolled back for the others.
|
||||
*/
|
||||
async bulkFulfill(
|
||||
requestIds: string[],
|
||||
@@ -212,13 +386,14 @@ export class WagonTransferRequestsService {
|
||||
skipped.push({ id, reason: 'Request not found' });
|
||||
continue;
|
||||
}
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Already ${request.status.toLowerCase()}`,
|
||||
reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const remaining = this.remainingOn(request);
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
@@ -226,12 +401,12 @@ export class WagonTransferRequestsService {
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: request.quantity,
|
||||
take: remaining,
|
||||
});
|
||||
if (wagons.length < request.quantity) {
|
||||
if (wagons.length === 0) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||
reason: 'No available wagons of this type in the source yard — left open',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -240,10 +415,7 @@ export class WagonTransferRequestsService {
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagons.length, userId);
|
||||
fulfilled.push(await this.findById(id));
|
||||
}
|
||||
|
||||
@@ -258,17 +430,25 @@ export class WagonTransferRequestsService {
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
async getHistory(
|
||||
userId?: string | null,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<TransferHistory> {
|
||||
const take = Math.min(pageSize ?? 20, HISTORY_LIMIT);
|
||||
const skip = ((page ?? 1) - 1) * take;
|
||||
|
||||
const [requests, requestsTotal] = await this.requestRepo.findAndCount({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
const [movements, movementsTotal] = await this.movementRepo.findAndCount({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
@@ -276,18 +456,39 @@ export class WagonTransferRequestsService {
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
return {
|
||||
requests,
|
||||
movements,
|
||||
meta: {
|
||||
page: page ?? 1,
|
||||
pageSize: take,
|
||||
requestsTotal,
|
||||
movementsTotal,
|
||||
// Whichever list is longer decides how far the pager can go.
|
||||
totalPages: Math.max(
|
||||
1,
|
||||
Math.ceil(Math.max(requestsTotal, movementsTotal) / take),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
/**
|
||||
* Withdraw a request before anything moved. Once wagons have been delivered
|
||||
* the request can only be completed or closed short — cancelling would erase
|
||||
* the fact that a transfer happened.
|
||||
*/
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
request.status === WagonTransferRequestStatus.PartiallyFulfilled
|
||||
? 'Wagons have already moved against this request — close it short instead of cancelling'
|
||||
: `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`,
|
||||
);
|
||||
}
|
||||
request.status = WagonTransferRequestStatus.Cancelled;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { WagonsController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
@@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
// The transfer desk notifies the requester as instalments land.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
|
||||
@@ -233,6 +233,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
||||
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
||||
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
||||
// The transfer desk is its own screen, so it carries its own per-action keys —
|
||||
// seeing the queue, withdrawing a request and short-closing one are separate
|
||||
// grants from filing or fulfilling.
|
||||
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
||||
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
||||
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||
@@ -524,6 +530,12 @@ export const FREIGHT_PERMS = {
|
||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
||||
/** Open the transfer-requests desk (list + detail). */
|
||||
transferView: 'edr_freight_app:wagons:transfer_view',
|
||||
/** Withdraw a request that has not moved any wagon yet. */
|
||||
transferCancel: 'edr_freight_app:wagons:transfer_cancel',
|
||||
/** End a request short — anyone who can fulfil may also do this. */
|
||||
transferCloseShort: 'edr_freight_app:wagons:transfer_close_short',
|
||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||
// their own (the /history endpoint uses the caller id, backend-enforced).
|
||||
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
||||
@@ -905,6 +917,12 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
...ROLE_PERMISSION_PRESETS.director,
|
||||
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||
FREIGHT_PERMS.allocation.manage,
|
||||
// Customer desk: onboarding intake lands on the chief — open the customer
|
||||
// list and approve/suspend a submitted profile. Deliberately NOT granted:
|
||||
// create, update and password reset, which stay with the customer admins.
|
||||
FREIGHT_PERMS.customers.view,
|
||||
FREIGHT_PERMS.customers.verify,
|
||||
FREIGHT_PERMS.customers.deactivate,
|
||||
]),
|
||||
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
||||
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
Container,
|
||||
@@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
@@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Truck />,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Wagon Transfers",
|
||||
href: "/dashboard/wagon-transfers",
|
||||
icon: <ArrowLeftRight />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
@@ -1180,6 +1191,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagon-transfers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
]}
|
||||
>
|
||||
<WagonTransfersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
@@ -1410,6 +1434,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagon-transfers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
]}
|
||||
>
|
||||
<WagonTransfersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
|
||||
@@ -147,7 +147,7 @@ export const BookingDetailModal = ({
|
||||
<Group gap={4} justify="flex-end">
|
||||
{bookingWagons.map((w) => (
|
||||
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
|
||||
#{w.sequenceNo}
|
||||
#{w.position ?? w.sequenceNo}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -281,7 +281,7 @@ function WagonCar({
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
#{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
{isEmpty ? (
|
||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
@@ -425,7 +425,7 @@ function WagonCar({
|
||||
</Box>
|
||||
<div>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const RemoveBookingModal = ({
|
||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
<strong>Wagon Slot:</strong> #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export const TrainConsistView = ({
|
||||
<Box>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers, move containers to another wagon, or remove the booking
|
||||
|
||||
@@ -80,7 +80,7 @@ export const WagonCard = ({
|
||||
<div>
|
||||
<Group gap={4}>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{wagonType}
|
||||
@@ -205,7 +205,7 @@ export const WagonCard = ({
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
#{w.sequenceNo} ·{" "}
|
||||
#{w.position ?? w.sequenceNo} ·{" "}
|
||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
<Badge
|
||||
|
||||
@@ -1,613 +0,0 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PENDING = Freight.WagonTransferRequestStatus.Pending;
|
||||
const AVAILABLE = Freight.WagonStatus.Available;
|
||||
|
||||
const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
const typeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
|
||||
|
||||
/** Requester → destination + type + count summary line, reused in list and picker. */
|
||||
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={14} style={{ flexShrink: 0 }} />
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
{r.quantity}× {typeLabel(r.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
FULFILLED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
||||
* backend enforces the scope regardless of the toggle.
|
||||
*/
|
||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
||||
);
|
||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions(),
|
||||
enabled: opened && !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
||||
enabled: opened && scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
||||
|
||||
const roleBadge = (r: WagonTransferRequest) => {
|
||||
if (myId && r.fulfilledByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
fulfilled
|
||||
</Badge>
|
||||
);
|
||||
if (myId && r.requestedByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
requested
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{canSeeAll ? (
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={allStaff}
|
||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
||||
label="All staff"
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Requests{scopeAll ? "" : " you touched"}
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No requests yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<RequestSummary r={r} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{roleBadge(r)}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
||||
>
|
||||
{r.status.toLowerCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
{r.reason ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Reason: {r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Wagons moved
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon moves yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap={6}>
|
||||
{movements.map((m) => (
|
||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
||||
</Text>
|
||||
{m.transferRequestId ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
from request
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
* A second tab shows per-user transfer history.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: requests = [], isLoading } = useQuery({
|
||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
// Available wagons of the requested type sitting in the request's source yard.
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: active
|
||||
? {
|
||||
currentYardId: active.fromYardId,
|
||||
wagonTypeId: active.wagonTypeId,
|
||||
status: AVAILABLE,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: opened && Boolean(active),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
const bulkFulfill = useMutation(
|
||||
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
|
||||
);
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? fallback;
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const openPicker = (r: WagonTransferRequest) => {
|
||||
setActive(r);
|
||||
setPicked(new Set());
|
||||
};
|
||||
const closePicker = () => {
|
||||
setActive(null);
|
||||
setPicked(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (active && next.size >= active.quantity) return prev; // cap at quantity
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const need = active?.quantity ?? 0;
|
||||
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
|
||||
|
||||
const handleFulfill = async () => {
|
||||
if (!active || picked.size !== need) return;
|
||||
try {
|
||||
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
|
||||
toast({
|
||||
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
|
||||
active.toYard,
|
||||
)}`,
|
||||
});
|
||||
closePicker();
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (r: WagonTransferRequest) => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast({ title: "Request cancelled" });
|
||||
} catch (err) {
|
||||
showError(err, "Cancel failed");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelected = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Execute the ticked subset; whatever cannot run (not enough available
|
||||
// wagons, already decided) is reported and simply stays PENDING.
|
||||
const handleBulkFulfill = async () => {
|
||||
if (selected.size === 0) return;
|
||||
try {
|
||||
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
||||
setSelected(new Set());
|
||||
const skippedNote = res.skipped.length
|
||||
? ` · ${res.skipped.length} left pending (${res.skipped
|
||||
.map((s) => s.reason)
|
||||
.join('; ')})`
|
||||
: "";
|
||||
toast({
|
||||
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
||||
description: skippedNote || undefined,
|
||||
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err, "Bulk execute failed");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedWagons = useMemo(
|
||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||
[wagons],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="min(760px, 96vw)"
|
||||
radius="lg"
|
||||
centered
|
||||
overlayProps={{ blur: 2 }}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Inbox size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Wagon Transfer Requests</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{active
|
||||
? "Pick the wagons to move, then transfer"
|
||||
: "OCC queue — pick wagons and complete each move"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
||||
Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="queue">
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : requests.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>No pending transfer requests</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
When staff request a yard-to-yard wagon move, it appears here for
|
||||
you to fulfil.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
||||
everything unticked (or unexecutable) stays PENDING. */}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
label={
|
||||
selected.size > 0
|
||||
? `${selected.size} of ${requests.length} selected`
|
||||
: "Select all"
|
||||
}
|
||||
checked={selected.size === requests.length && requests.length > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < requests.length}
|
||||
onChange={() =>
|
||||
setSelected(
|
||||
selected.size === requests.length
|
||||
? new Set()
|
||||
: new Set(requests.map((r) => r.id)),
|
||||
)
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
loading={bulkFulfill.isPending}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleBulkFulfill}
|
||||
>
|
||||
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleSelected(r.id)}
|
||||
color="edr-green"
|
||||
mt={2}
|
||||
/>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.reason ? (
|
||||
<Text size="xs">
|
||||
<Text span fw={600}>
|
||||
Reason:
|
||||
</Text>{" "}
|
||||
{r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
loading={cancel.isPending}
|
||||
onClick={() => handleCancel(r)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
onClick={() => openPicker(r)}
|
||||
>
|
||||
Fulfil
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
) : (
|
||||
// ---- Wagon picker for the active request ----
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
|
||||
<RequestSummary r={active} />
|
||||
</Card>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Select wagons in {yardLabel(active.fromYard)}
|
||||
</Text>
|
||||
<Badge
|
||||
color={picked.size === need ? "teal" : "gray"}
|
||||
variant={picked.size === need ? "filled" : "light"}
|
||||
>
|
||||
{picked.size} / {need} selected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{wagonsLoading ? (
|
||||
<Group justify="center" p="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : sortedWagons.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group gap={8} justify="center">
|
||||
<Warehouse size={16} />
|
||||
<Text size="sm" c="dimmed">
|
||||
No available wagons of this type in {yardLabel(active.fromYard)}.
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{shortfall > 0 ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Only {sortedWagons.length} available — {shortfall} short of the{" "}
|
||||
{need} requested.
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={6}>
|
||||
{sortedWagons.map((w) => {
|
||||
const checked = picked.has(w.id);
|
||||
const atCap = !checked && picked.size >= need;
|
||||
return (
|
||||
<Card
|
||||
key={w.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="xs"
|
||||
onClick={() => !atCap && toggle(w.id)}
|
||||
style={{
|
||||
cursor: atCap ? "not-allowed" : "pointer",
|
||||
borderColor: checked
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: undefined,
|
||||
opacity: atCap ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{/* Visual only — the Card's onClick owns the toggle so a
|
||||
click on the box doesn't fire both and cancel out. */}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
readOnly
|
||||
disabled={atCap}
|
||||
color="edr-green"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={closePicker}
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size !== need}
|
||||
onClick={handleFulfill}
|
||||
>
|
||||
Transfer {need} wagon{need === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonTransferRequestsModal;
|
||||
@@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => {
|
||||
return Math.min(Math.floor(n), max);
|
||||
};
|
||||
|
||||
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
||||
/**
|
||||
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||||
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||||
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||||
* instalments) — the slider then just tracks the current value.
|
||||
*/
|
||||
const QuantityField = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -49,10 +54,11 @@ const QuantityField = ({
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
max: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const set = (v: number | string) => onChange(clampInt(v, max));
|
||||
const capped = max ?? Number.MAX_SAFE_INTEGER;
|
||||
const set = (v: number | string) => onChange(clampInt(v, capped));
|
||||
const off = disabled || max === 0;
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
@@ -73,19 +79,25 @@ const QuantityField = ({
|
||||
value={value}
|
||||
onChange={set}
|
||||
min={0}
|
||||
max={Math.max(max, 1)}
|
||||
max={Math.max(max ?? Math.max(value, 10), 1)}
|
||||
disabled={off}
|
||||
label={(v) => `${v}`}
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
{/* Presets only make sense against a real ceiling — an uncapped request
|
||||
field (transfer ask) shows the manual input alone. */}
|
||||
{max != null ? (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{value > 0 ? (
|
||||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||
Clear
|
||||
@@ -242,9 +254,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Keep quantities within bounds as counts shift after each action. Transfers
|
||||
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
// Keep quantities within bounds as counts shift after each action. A TRANSFER
|
||||
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
|
||||
// 50 where 20 sit today is normal — only the status flips below are bounded by
|
||||
// what is physically in the yard.
|
||||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||
|
||||
@@ -453,11 +466,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{availableCount} available
|
||||
</Badge>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
{/* No max: the request may exceed what the yard holds
|
||||
today — OCC fulfils it in instalments. */}
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
|
||||
@@ -130,6 +130,10 @@ export const FREIGHT_PERMS = {
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
/** Open the transfer desk. `wagons:view` is accepted as a fallback. */
|
||||
transferView: "edr_freight_app:wagons:transfer_view",
|
||||
transferCancel: "edr_freight_app:wagons:transfer_cancel",
|
||||
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions"
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
@@ -19,7 +19,6 @@ import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -67,7 +66,6 @@ const FleetResourcePage = () => {
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
@@ -90,6 +88,11 @@ const FleetResourcePage = () => {
|
||||
if (trainNumber && trainNumber !== "ALL") {
|
||||
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
||||
}
|
||||
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
|
||||
const wagonTypeId = listFilterValues.wagonTypeId;
|
||||
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
|
||||
}
|
||||
if (slug !== "locomotives" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
}
|
||||
@@ -449,12 +452,15 @@ const FleetResourcePage = () => {
|
||||
</Button>
|
||||
) : null}
|
||||
{canTransfer ? (
|
||||
// The desk is its own page now (list + fulfil + history with
|
||||
// pagination); this is just the way in from the fleet list.
|
||||
<Button
|
||||
component={Link}
|
||||
to="/dashboard/wagon-transfers"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
@@ -713,13 +719,6 @@ const FleetResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonTransferRequestsModal
|
||||
opened={transferRequestsOpen}
|
||||
onClose={() => setTransferRequestsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
|
||||
@@ -305,6 +305,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
allLabel: "All statuses",
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
allLabel: "All types",
|
||||
dynamicOptions: "wagonTypes",
|
||||
},
|
||||
{
|
||||
key: "currentYardId",
|
||||
label: "Current Yard",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||
|
||||
export interface TransferFulfillModalProps {
|
||||
request: WagonTransferRequest | null;
|
||||
onClose: () => void;
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move wagons against an open request. Any number from one up to whatever is
|
||||
* still owed — a yard that can only spare 20 of 50 sends 20 now and the request
|
||||
* stays open for the rest, so the picker caps at the OUTSTANDING count, not the
|
||||
* originally requested one.
|
||||
*/
|
||||
export function TransferFulfillModal({
|
||||
request,
|
||||
onClose,
|
||||
onDone,
|
||||
}: TransferFulfillModalProps) {
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const outstanding = request ? outstandingOn(request) : 0;
|
||||
|
||||
const { data: wagons = [], isLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: request
|
||||
? {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: Freight.WagonStatus.Available,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: Boolean(request),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
|
||||
const canTake = useMemo(
|
||||
() => Math.min(outstanding, wagons.length),
|
||||
[outstanding, wagons.length],
|
||||
);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
// Never let staff pick more than is still owed — the API rejects it too.
|
||||
else if (next.size >= outstanding) return prev;
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const takeAllAvailable = () =>
|
||||
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
|
||||
|
||||
const close = () => {
|
||||
setPicked(new Set());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!request || picked.size === 0) return;
|
||||
try {
|
||||
const moved = picked.size;
|
||||
await fulfill.mutateAsync({ id: request.id, wagonIds: [...picked] });
|
||||
toast.success(
|
||||
moved >= outstanding
|
||||
? `Request complete — ${moved} wagon(s) transferred`
|
||||
: `${moved} wagon(s) transferred · ${outstanding - moved} still owed`,
|
||||
);
|
||||
close();
|
||||
onDone?.();
|
||||
} catch {
|
||||
// The http interceptor surfaces the server's reason.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(request)}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
radius="md"
|
||||
title={
|
||||
request ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={700}>{yardLabel(request.fromYard)}</Text>
|
||||
<ArrowRight size={15} />
|
||||
<Text fw={700}>{yardLabel(request.toYard)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!request ? null : (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm">
|
||||
<Text span fw={700}>
|
||||
{outstanding}
|
||||
</Text>{" "}
|
||||
wagon(s) still owed ·{" "}
|
||||
<Text span fw={700}>
|
||||
{wagons.length}
|
||||
</Text>{" "}
|
||||
available in {yardLabel(request.fromYard)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={canTake === 0}
|
||||
onClick={takeAllAvailable}
|
||||
>
|
||||
Select {canTake}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{wagons.length < outstanding ? (
|
||||
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
This yard can only cover {wagons.length} of the {outstanding}{" "}
|
||||
outstanding. Send what is here — the request stays open for the
|
||||
rest, or close it short so the requester can ask another yard.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" py="md">
|
||||
No available wagons of this type in the source yard right now.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={4}>
|
||||
{wagons.map((w) => (
|
||||
<Checkbox
|
||||
key={w.id}
|
||||
checked={picked.has(w.id)}
|
||||
onChange={() => toggle(w.id)}
|
||||
label={w.wagonNumber}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size === 0}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Transfer {picked.size || ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransferFulfillModal;
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Send, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||
|
||||
/** Yard + wagon-type option lists, shared by both modals. */
|
||||
function useTransferOptions(enabled: boolean) {
|
||||
const { data: yards = [] } = useQuery({
|
||||
...api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
enabled,
|
||||
});
|
||||
const { data: wagonTypes = [] } = useQuery({
|
||||
...api.wagonTypes.list.queryOptions(),
|
||||
enabled,
|
||||
});
|
||||
return {
|
||||
yardOptions: yards.map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
})),
|
||||
typeOptions: wagonTypes.map((t) => ({
|
||||
value: t.id,
|
||||
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferRequestFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Carry-over from a request that could not be met in full: the destination,
|
||||
* type, outstanding count and reason are pre-filled and the user only picks
|
||||
* WHICH other yard to ask. Undefined for a plain new request.
|
||||
*/
|
||||
prefillFrom?: WagonTransferRequest | null;
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a wagon-transfer request. The count is deliberately NOT capped by what
|
||||
* the source yard holds today — OCC fulfils in instalments, so asking for 50
|
||||
* where 20 sit is a normal request.
|
||||
*/
|
||||
export function TransferRequestFormModal({
|
||||
opened,
|
||||
onClose,
|
||||
prefillFrom,
|
||||
onCreated,
|
||||
}: TransferRequestFormModalProps) {
|
||||
const { yardOptions, typeOptions } = useTransferOptions(opened);
|
||||
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||
const [quantity, setQuantity] = useState<number | string>(1);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
// Re-seed on every open so a carry-over never leaks into the next request.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setFromYardId(null); // always chosen fresh — that is the point of a re-ask
|
||||
setToYardId(prefillFrom?.toYardId ?? null);
|
||||
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
|
||||
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
|
||||
setReason(prefillFrom?.reason ?? "");
|
||||
}, [opened, prefillFrom]);
|
||||
|
||||
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
|
||||
|
||||
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
|
||||
const valid =
|
||||
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
|
||||
!sameYard &&
|
||||
Number(quantity) >= 1;
|
||||
|
||||
const submit = async () => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
fromYardId: fromYardId!,
|
||||
toYardId: toYardId!,
|
||||
wagonTypeId: wagonTypeId!,
|
||||
quantity: Number(quantity),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
toast.success("Transfer request filed");
|
||||
onClose();
|
||||
onCreated?.();
|
||||
} catch {
|
||||
// Server reason is surfaced by the http interceptor.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={prefillFrom ? "Request the rest from another yard" : "New transfer request"}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{prefillFrom ? (
|
||||
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
{yardLabel(prefillFrom.fromYard)} supplied{" "}
|
||||
{prefillFrom.fulfilledQuantity} of {prefillFrom.quantity}. Pick
|
||||
another yard to cover the remaining {outstandingOn(prefillFrom)}.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
label="Source yard"
|
||||
description={prefillFrom ? "Which yard should supply the rest" : undefined}
|
||||
placeholder="Where the wagons come from"
|
||||
data={yardOptions}
|
||||
value={fromYardId}
|
||||
onChange={setFromYardId}
|
||||
searchable
|
||||
required
|
||||
error={sameYard ? "Source and destination must differ" : undefined}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder="Where they are needed"
|
||||
data={yardOptions}
|
||||
value={toYardId}
|
||||
onChange={setToYardId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Type of wagon"
|
||||
data={typeOptions}
|
||||
value={wagonTypeId}
|
||||
onChange={setWagonTypeId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="How many"
|
||||
description="Can exceed what the yard holds today — OCC delivers in instalments"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why the wagons are needed"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
loading={create.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
File request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TransferCloseShortModalProps {
|
||||
request: WagonTransferRequest | null;
|
||||
onClose: () => void;
|
||||
onClosed?: (request: WagonTransferRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a request the source yard cannot finish. What already moved stays moved;
|
||||
* the requester is notified of the shortfall so they can raise it elsewhere.
|
||||
*/
|
||||
export function TransferCloseShortModal({
|
||||
request,
|
||||
onClose,
|
||||
onClosed,
|
||||
}: TransferCloseShortModalProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const closeShort = useMutation(
|
||||
api.wagonTransferRequests.closeShort.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) setNote("");
|
||||
}, [request]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!request) return;
|
||||
try {
|
||||
await closeShort.mutateAsync({ id: request.id, note: note.trim() || undefined });
|
||||
toast.success("Request closed — the requester has been notified");
|
||||
onClose();
|
||||
onClosed?.(request);
|
||||
} catch {
|
||||
// Server reason surfaced by the http interceptor.
|
||||
}
|
||||
};
|
||||
|
||||
const outstanding = request ? outstandingOn(request) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(request)}
|
||||
onClose={onClose}
|
||||
radius="md"
|
||||
title="Close this request short"
|
||||
>
|
||||
{!request ? null : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{yardLabel(request.fromYard)} → {yardLabel(request.toYard)} ·{" "}
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Text>
|
||||
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
{request.fulfilledQuantity} of {request.quantity} wagon(s) have been
|
||||
supplied. Closing leaves {outstanding} undelivered — the requester is
|
||||
told to ask another yard.
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Why can't the yard supply the rest?"
|
||||
description="Included in the requester's notification"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Keep it open
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={15} />}
|
||||
loading={closeShort.isPending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Close short
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
TransferRequestListFilter,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import TransferFulfillModal from "./TransferFulfillModal";
|
||||
import {
|
||||
TransferCloseShortModal,
|
||||
TransferRequestFormModal,
|
||||
} from "./TransferRequestModals";
|
||||
import {
|
||||
TransferProgress,
|
||||
TransferStatusBadge,
|
||||
fmtDateTime,
|
||||
isOpenRequest,
|
||||
outstandingOn,
|
||||
wagonTypeLabel,
|
||||
yardLabel,
|
||||
} from "./wagon-transfer-ui";
|
||||
|
||||
const S = Freight.WagonTransferRequestStatus;
|
||||
|
||||
/** "Open" is the working set: nothing delivered yet OR part-delivered. */
|
||||
const OPEN_STATUSES = `${S.Pending},${S.PartiallyFulfilled}`;
|
||||
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: OPEN_STATUSES, label: "Open (awaiting wagons)" },
|
||||
{ value: S.Pending, label: "Not started" },
|
||||
{ value: S.PartiallyFulfilled, label: "Partly delivered" },
|
||||
{ value: S.Fulfilled, label: "Complete" },
|
||||
{ value: S.ClosedShort, label: "Closed short" },
|
||||
{ value: S.Cancelled, label: "Cancelled" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The wagon-transfer desk.
|
||||
*
|
||||
* A request is a count, not a wagon list: someone asks for 50 gondolas from
|
||||
* Dire Dawa, and OCC sends whatever that yard can spare, whenever it can. The
|
||||
* table is built around that — every row shows delivered-vs-asked, and a
|
||||
* request only leaves the queue when it is fully supplied or explicitly closed
|
||||
* short (which tells the requester to try another yard).
|
||||
*/
|
||||
export default function WagonTransfersPage() {
|
||||
const { user } = useAuth();
|
||||
const canRequest = hasPermission(user, FREIGHT_PERMS.wagons.transferRequest);
|
||||
const canFulfil = hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill);
|
||||
const canCloseShort =
|
||||
canFulfil || hasPermission(user, FREIGHT_PERMS.wagons.transferCloseShort);
|
||||
const canCancel =
|
||||
canRequest || hasPermission(user, FREIGHT_PERMS.wagons.transferCancel);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(OPEN_STATUSES);
|
||||
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [carryOver, setCarryOver] = useState<WagonTransferRequest | null>(null);
|
||||
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
|
||||
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const filter: TransferRequestListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(fromYardId ? { fromYardId } : {}),
|
||||
...(toYardId ? { toYardId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
status,
|
||||
fromYardId,
|
||||
toYardId,
|
||||
wagonTypeId,
|
||||
debouncedSearch,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.wagonTransferRequests.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
const rows = data?.items ?? [];
|
||||
const meta = data?.meta;
|
||||
|
||||
const { data: yards = [] } = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||||
const yardOptions = yards.map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
}));
|
||||
const typeOptions = wagonTypes.map((t) => ({
|
||||
value: t.id,
|
||||
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatus(OPEN_STATUSES);
|
||||
setFromYardId(null);
|
||||
setToYardId(null);
|
||||
setWagonTypeId(null);
|
||||
setSearch("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<WagonTransferRequest>[] = [
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={13} className="shrink-0 opacity-60" />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: () => <span>Wagon type</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
header: () => <span>Delivered</span>,
|
||||
cell: ({ row }) => <TransferProgress request={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "reason",
|
||||
header: () => <span>Reason</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
|
||||
{row.original.reason || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "filed",
|
||||
header: () => <span>Filed</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDateTime(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => <TransferStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const open = isOpenRequest(r);
|
||||
const short =
|
||||
r.status === S.ClosedShort && outstandingOn(r) > 0;
|
||||
return (
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
{open && canFulfil ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={13} />}
|
||||
onClick={() => setFulfilling(r)}
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
) : null}
|
||||
{open && r.fulfilledQuantity > 0 && canCloseShort ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<XCircle size={13} />}
|
||||
onClick={() => setClosingShort(r)}
|
||||
>
|
||||
Close short
|
||||
</Button>
|
||||
) : null}
|
||||
{short && canRequest ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Send size={13} />}
|
||||
onClick={() => {
|
||||
setCarryOver(r);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Ask another yard
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === S.Pending && canCancel ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={cancel.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast.success("Request withdrawn");
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const openCount = rows.filter(isOpenRequest).length;
|
||||
const outstandingWagons = rows.reduce(
|
||||
(sum: number, r: WagonTransferRequest) =>
|
||||
sum + (isOpenRequest(r) ? outstandingOn(r) : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Wagon transfers"
|
||||
subtitle="Requests for wagons to move between yards — delivered in instalments until the full count is met"
|
||||
breadcrumbs={[
|
||||
{ label: "Wagons", href: "/dashboard/wagons" },
|
||||
{ label: "Transfers" },
|
||||
]}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{canRequest ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
onClick={() => {
|
||||
setCarryOver(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
New request
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Open on this page",
|
||||
value: openCount,
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
label: "Wagons still owed",
|
||||
value: outstandingWagons,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Requests matched",
|
||||
value: meta?.total ?? 0,
|
||||
icon: PackageCheck,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="requests" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="requests" leftSection={<Inbox size={15} />}>
|
||||
Requests
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={15} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="requests" pt="md">
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search the reason…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
w={240}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={STATUS_FILTER_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
w={200}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="From yard"
|
||||
data={yardOptions}
|
||||
value={fromYardId}
|
||||
onChange={(v) => {
|
||||
setFromYardId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="To yard"
|
||||
data={yardOptions}
|
||||
value={toYardId}
|
||||
onChange={(v) => {
|
||||
setToYardId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Wagon type"
|
||||
data={typeOptions}
|
||||
value={wagonTypeId}
|
||||
onChange={(v) => {
|
||||
setWagonTypeId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Button variant="subtle" radius="md" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
totalCount: meta?.total ?? 0,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<TransferHistoryPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<TransferRequestFormModal
|
||||
opened={formOpen}
|
||||
prefillFrom={carryOver}
|
||||
onClose={() => {
|
||||
setFormOpen(false);
|
||||
setCarryOver(null);
|
||||
}}
|
||||
onCreated={() => void refetch()}
|
||||
/>
|
||||
<TransferFulfillModal
|
||||
request={fulfilling}
|
||||
onClose={() => setFulfilling(null)}
|
||||
onDone={() => void refetch()}
|
||||
/>
|
||||
<TransferCloseShortModal
|
||||
request={closingShort}
|
||||
onClose={() => setClosingShort(null)}
|
||||
onClosed={(r) => {
|
||||
void refetch();
|
||||
// Straight into the re-ask: the shortfall is the whole reason this
|
||||
// request was closed, so offer the other-yard form immediately.
|
||||
if (canRequest) {
|
||||
setCarryOver(r);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who moved what. A staffer sees their own activity; holders of
|
||||
* `transfer_history_all` can widen it to every staffer (the backend enforces
|
||||
* the scope regardless of the toggle).
|
||||
*/
|
||||
function TransferHistoryPanel() {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements = source.data?.movements ?? [];
|
||||
const meta = source.data?.meta;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={600}>Transfer history</Text>
|
||||
{canSeeAll ? (
|
||||
<Switch
|
||||
label="All staff"
|
||||
checked={allStaff}
|
||||
onChange={(e) => {
|
||||
setAllStaff(e.currentTarget.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="flex-start" grow gap="lg" wrap="wrap">
|
||||
<Stack gap={6} miw={280}>
|
||||
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||
Requests ({meta?.requestsTotal ?? 0})
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing yet.
|
||||
</Text>
|
||||
) : (
|
||||
requests.map((r) => (
|
||||
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{yardLabel(r.fromYard)} → {yardLabel(r.toYard)} ·{" "}
|
||||
{r.fulfilledQuantity}/{r.quantity}
|
||||
</Text>
|
||||
<TransferStatusBadge status={r.status} />
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6} miw={280}>
|
||||
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||
Wagons moved ({meta?.movementsTotal ?? 0})
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing yet.
|
||||
</Text>
|
||||
) : (
|
||||
movements.map((m) => (
|
||||
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} →{" "}
|
||||
{yardLabel(m.toYard)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="center" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={page >= (meta?.totalPages ?? 1)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
|
||||
export const wagonTypeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? [t.code, t.name].filter(Boolean).join(" · ") : "—";
|
||||
|
||||
/** Wagons still owed on a request (0 once it is complete or closed). */
|
||||
export const outstandingOn = (r: WagonTransferRequest): number =>
|
||||
Math.max(0, r.quantity - (r.fulfilledQuantity ?? 0));
|
||||
|
||||
/** A request OCC can still move wagons against. */
|
||||
export const isOpenRequest = (r: WagonTransferRequest): boolean =>
|
||||
r.status === Freight.WagonTransferRequestStatus.Pending ||
|
||||
r.status === Freight.WagonTransferRequestStatus.PartiallyFulfilled;
|
||||
|
||||
export const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: "Awaiting wagons", color: "gray" },
|
||||
PARTIALLY_FULFILLED: { label: "Partly delivered", color: "yellow" },
|
||||
FULFILLED: { label: "Complete", color: "teal" },
|
||||
CLOSED_SHORT: { label: "Closed short", color: "orange" },
|
||||
CANCELLED: { label: "Cancelled", color: "red" },
|
||||
};
|
||||
|
||||
export function TransferStatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_META[status] ?? { label: status, color: "gray" };
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivered-vs-asked bar. The number is what staff actually need — the bar just
|
||||
* makes "nearly there" vs "barely started" readable at a glance across a page
|
||||
* of requests.
|
||||
*/
|
||||
export function TransferProgress({ request }: { request: WagonTransferRequest }) {
|
||||
const delivered = request.fulfilledQuantity ?? 0;
|
||||
const percent = request.quantity > 0 ? (delivered / request.quantity) * 100 : 0;
|
||||
const outstanding = outstandingOn(request);
|
||||
const complete = delivered >= request.quantity;
|
||||
const closedShort =
|
||||
request.status === Freight.WagonTransferRequestStatus.ClosedShort;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
complete
|
||||
? "Fully supplied"
|
||||
: closedShort
|
||||
? `Closed ${outstanding} wagon(s) short`
|
||||
: `${outstanding} wagon(s) still to come`
|
||||
}
|
||||
>
|
||||
<Box miw={110}>
|
||||
<Group gap={6} justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{delivered} / {request.quantity}
|
||||
</Text>
|
||||
{!complete && !closedShort ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{outstanding} left
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={complete ? "teal" : closedShort ? "orange" : "yellow"}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const fmtDateTime = (iso?: string | null) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "—";
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -208,6 +210,7 @@ import {
|
||||
type CreateTransferRequestPayload,
|
||||
type BulkFulfillResult,
|
||||
type TransferHistory,
|
||||
type TransferRequestListFilter,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -1719,14 +1722,14 @@ export const api = {
|
||||
|
||||
wagonTransferRequests: {
|
||||
list: endpoint<
|
||||
{ status?: WagonTransferRequest["status"] },
|
||||
WagonTransferRequest[]
|
||||
{ filter?: TransferRequestListFilter },
|
||||
PaginatedResponse<WagonTransferRequest>
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"list",
|
||||
({ status }) =>
|
||||
wagonTransferRequestService.list(status).then((r) => r.data),
|
||||
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
|
||||
({ filter }) =>
|
||||
wagonTransferRequestService.list(filter).then((r) => r.data),
|
||||
({ filter }) => ["wagonTransferRequests", "list", filter ?? {}],
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, WagonTransferRequest>(
|
||||
@@ -1774,19 +1777,47 @@ export const api = {
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
history: endpoint<void, TransferHistory>(
|
||||
closeShort: endpoint<{ id: string; note?: string }, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
||||
() => ["wagonTransferRequests", "history", "mine"],
|
||||
"closeShort",
|
||||
({ id, note }) =>
|
||||
wagonTransferRequestService.closeShort(id, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
||||
history: endpoint<{ page?: number; pageSize?: number }, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
({ page, pageSize }) =>
|
||||
wagonTransferRequestService.myHistory(page, pageSize).then((r) => r.data),
|
||||
({ page, pageSize }) => [
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
"mine",
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
],
|
||||
),
|
||||
|
||||
historyAll: endpoint<
|
||||
{ userId?: string; page?: number; pageSize?: number },
|
||||
TransferHistory
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"historyAll",
|
||||
({ userId }) =>
|
||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
||||
({ userId, page, pageSize }) =>
|
||||
wagonTransferRequestService
|
||||
.allHistory(userId, page, pageSize)
|
||||
.then((r) => r.data),
|
||||
({ userId, page, pageSize }) => [
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
"all",
|
||||
userId ?? "",
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
],
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
@@ -109,10 +109,15 @@ export interface WagonTransferRequest {
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
quantity: number;
|
||||
/** How many have actually moved so far — OCC delivers in instalments. */
|
||||
fulfilledQuantity: number;
|
||||
status: Freight.WagonTransferRequestStatus;
|
||||
requestedByUserId: string | null;
|
||||
fulfilledByUserId: string | null;
|
||||
/** When the LAST instalment ran, not necessarily the full count. */
|
||||
fulfilledAt: string | null;
|
||||
closedShortAt?: string | null;
|
||||
closedShortByUserId?: string | null;
|
||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||
reason?: string | null;
|
||||
note: string | null;
|
||||
@@ -132,7 +137,7 @@ export interface CreateTransferRequestPayload {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
|
||||
/** Bulk accept-and-execute result: what ran, what stayed open and why. */
|
||||
export interface BulkFulfillResult {
|
||||
fulfilled: WagonTransferRequest[];
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
@@ -142,20 +147,52 @@ export interface BulkFulfillResult {
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovementRecord[];
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
requestsTotal: number;
|
||||
movementsTotal: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Desk list filters — `status` may be a comma-separated set ("open" tab). */
|
||||
export interface TransferRequestListFilter {
|
||||
status?: string;
|
||||
fromYardId?: string;
|
||||
toYardId?: string;
|
||||
wagonTypeId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const listQuery = (filter: TransferRequestListFilter = {}): string => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
};
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
list: (filter: TransferRequestListFilter = {}) =>
|
||||
apiClient.get<PaginatedResponse<WagonTransferRequest>>(
|
||||
`/wagon-transfer-requests${listQuery(filter)}`,
|
||||
),
|
||||
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
||||
myHistory: () =>
|
||||
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string) =>
|
||||
myHistory: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
||||
`/wagon-transfer-requests/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string, page = 1, pageSize = 20) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all?page=${page}&pageSize=${pageSize}` +
|
||||
`${userId ? `&userId=${userId}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
@@ -176,4 +213,10 @@ export const wagonTransferRequestService = {
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/cancel`,
|
||||
),
|
||||
/** OCC: end the request short — the source yard has no more to give. */
|
||||
closeShort: (id: string, note?: string) =>
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/close-short`,
|
||||
{ note },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -616,6 +616,12 @@ export interface TrainScheduleDetail {
|
||||
wagons: Array<{
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
/**
|
||||
* Place in the drawn consist, 1..n — the built train's real coupling
|
||||
* order (reversed for a reverseWagonOrder schedule). Label wagons with
|
||||
* this, not sequenceNo: a slot's stored sequenceNo is not its position.
|
||||
*/
|
||||
position?: number;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
assignedWeightTons: number;
|
||||
|
||||
@@ -95,7 +95,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||
// Validity defaults to the first configured option in the accept modal.
|
||||
// The modal takes an explicit validity window and pre-fills neither date.
|
||||
cy.acceptValidityWindow();
|
||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
@@ -146,7 +147,11 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
);
|
||||
cy.wait(500).then(() => {
|
||||
cy.get("body").then(($b) => {
|
||||
if ($b.text().includes("I have read the entire contract")) return;
|
||||
// Probe the description that only renders once hasScrolledToBottom
|
||||
// flips — the consent LABEL is always in the DOM (just its checkbox
|
||||
// is disabled), so matching on it raced ahead of the scroll handler
|
||||
// and left the Sign button disabled.
|
||||
if ($b.text().includes("You may now sign the contract")) return;
|
||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||
unlockConsent(attempt + 1);
|
||||
});
|
||||
@@ -154,7 +159,12 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
};
|
||||
unlockConsent(0);
|
||||
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||
// Check the input itself — clicking the label text lands on the Checkbox's
|
||||
// label span, which does not toggle it, leaving Sign disabled.
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 })
|
||||
.closest(".mantine-Checkbox-root")
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||
|
||||
// Signature modal: name + drawn signature.
|
||||
@@ -164,6 +174,7 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.uploadCompanyStamp();
|
||||
cy.contains("button", "Continue to verification").click();
|
||||
|
||||
// OTP modal — code goes to the signer's registered contacts (email only
|
||||
@@ -191,6 +202,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
|
||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||
});
|
||||
cy.drawSignature();
|
||||
// Staff counter-sign gates on a stamp too, same as the customer's modal.
|
||||
cy.uploadCompanyStamp();
|
||||
// Scoped to the modal — the toolbar behind it has its own "Approve & sign".
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||
|
||||
@@ -166,6 +166,8 @@ function approveChain(freight: "CONTAINER" | "BULK") {
|
||||
cy.loginBackoffice("marketer@edr.local");
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||
// The modal takes an explicit validity window and pre-fills neither date.
|
||||
cy.acceptValidityWindow();
|
||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
@@ -198,7 +200,9 @@ function customerSigns(freight: "CONTAINER" | "BULK") {
|
||||
});
|
||||
cy.wait(500).then(() => {
|
||||
cy.get("body").then(($b) => {
|
||||
if ($b.text().includes("I have read the entire contract")) return;
|
||||
// Only rendered once hasScrolledToBottom flips; the consent label is
|
||||
// always present, so matching it raced ahead of the scroll handler.
|
||||
if ($b.text().includes("You may now sign the contract")) return;
|
||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||
unlockConsent(attempt + 1);
|
||||
});
|
||||
@@ -206,7 +210,11 @@ function customerSigns(freight: "CONTAINER" | "BULK") {
|
||||
};
|
||||
unlockConsent(0);
|
||||
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||
// Check the input itself — clicking the label text does not toggle it.
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 })
|
||||
.closest(".mantine-Checkbox-root")
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||
cy.contains("label", "Full name")
|
||||
.invoke("attr", "for")
|
||||
@@ -214,6 +222,7 @@ function customerSigns(freight: "CONTAINER" | "BULK") {
|
||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.uploadCompanyStamp();
|
||||
cy.contains("button", "Continue to verification").click();
|
||||
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||||
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
|
||||
@@ -233,6 +242,8 @@ function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
|
||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||
});
|
||||
cy.drawSignature();
|
||||
// Staff counter-sign gates on a stamp too, same as the customer's modal.
|
||||
cy.uploadCompanyStamp();
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||
.click();
|
||||
@@ -240,8 +251,11 @@ function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
|
||||
expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS");
|
||||
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||
// Clearance review + finalize live solely on the Operations "Clearance
|
||||
// Documents" hub — the contract detail page no longer embeds a tab for it.
|
||||
withContract(freight, (c) =>
|
||||
cy.visit(`/dashboard/contracts/clearance-documents/${c.id}`),
|
||||
);
|
||||
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
@@ -342,7 +356,14 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||||
// Anchor on the FIRST yard in the label. The corridor also has the
|
||||
// reverse (import) route, whose label ends at this same yard — an
|
||||
// unanchored match picks it, and the API then rejects the departure
|
||||
// against the 3-day import lead instead of the 24h export one.
|
||||
cy.mantineSelect(
|
||||
/^Route$/,
|
||||
new RegExp(`^\\s*${ORIGIN_YARD}.*${PORT_YARD}\\s*$`),
|
||||
);
|
||||
|
||||
// Just past the 24h scheduling lead: creatable now, and the export
|
||||
// window (opens departure − lead) flips OPEN a couple of minutes later.
|
||||
@@ -362,7 +383,24 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
);
|
||||
});
|
||||
|
||||
// The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes.
|
||||
// The export window opens at departure − 24h CLAMPED into the booking desk
|
||||
// hours (8–17 EAT), so a run outside those hours would sit at PRE_WINDOW
|
||||
// until the clamp time no matter how long it waited. Force it OPEN — this
|
||||
// spec exercises the FCFS reservation flow, not the window engine
|
||||
// (segment_weight arranges its windows the same way).
|
||||
dbUpcomingSchedule().then(({ rows }) => {
|
||||
expect(rows, "upcoming export schedule").to.have.length(1);
|
||||
cy.task("db:query", {
|
||||
sql: `UPDATE freight.train_schedules
|
||||
SET window_opens_at = LEAST(window_opens_at, now()),
|
||||
window_phase = 'OPEN',
|
||||
booking_window_status = 'OPEN'
|
||||
WHERE id = $1 AND booking_window_status <> 'FULL'`,
|
||||
params: [rows[0].id],
|
||||
});
|
||||
});
|
||||
|
||||
// Belt-and-braces: confirm the engine keeps it OPEN.
|
||||
const waitForOpenWindow = (attempt: number) => {
|
||||
dbUpcomingSchedule().then(({ rows }) => {
|
||||
expect(rows, "upcoming export schedule").to.have.length(1);
|
||||
@@ -434,6 +472,7 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
|
||||
pickShipmentDay();
|
||||
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
@@ -501,6 +540,7 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
||||
fill(/^Quantity \(tons\)/, "60");
|
||||
pickShipmentDay();
|
||||
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
|
||||
@@ -113,24 +113,36 @@ function withBooking(
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest export schedule created by this journey. */
|
||||
/**
|
||||
* The export schedule this journey rides — scoped to a MOJO-origin EXPORT run,
|
||||
* the same shape the create-guard below counts. Taking the newest row in the
|
||||
* whole table instead picked up whatever schedule another spec happened to
|
||||
* create last (an IMPORT one, in a full-suite run).
|
||||
*/
|
||||
function dbSchedule() {
|
||||
return cy.task<{
|
||||
rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>;
|
||||
}>("db:query", {
|
||||
sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.routes r ON r.id = ts.route_id
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill a labelled Mantine input (label[for] → input id). */
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
/**
|
||||
* Fill the N-th labelled Mantine input (label[for] → input id). Indexed
|
||||
* because the booking form renders one "Quantity *" per container size.
|
||||
*/
|
||||
function fillNth(label: RegExp, index: number, value: string) {
|
||||
cy.get("label").then(($labels) => {
|
||||
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
|
||||
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
|
||||
const id = matches.eq(index).attr("for");
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,6 +240,8 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||
// The modal takes an explicit validity window and pre-fills neither date.
|
||||
cy.acceptValidityWindow();
|
||||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
@@ -267,7 +281,9 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
});
|
||||
cy.wait(500).then(() => {
|
||||
cy.get("body").then(($b) => {
|
||||
if ($b.text().includes("I have read the entire contract")) return;
|
||||
// Only rendered once hasScrolledToBottom flips; the consent label is
|
||||
// always present, so matching it raced ahead of the scroll handler.
|
||||
if ($b.text().includes("You may now sign the contract")) return;
|
||||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||
unlockConsent(attempt + 1);
|
||||
});
|
||||
@@ -275,7 +291,11 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
};
|
||||
unlockConsent(0);
|
||||
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||
// Check the input itself — clicking the label text does not toggle it.
|
||||
cy.contains("I have read the entire contract", { timeout: 15000 })
|
||||
.closest(".mantine-Checkbox-root")
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||
|
||||
cy.contains("label", "Full name")
|
||||
@@ -284,6 +304,7 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||
});
|
||||
cy.drawSignature();
|
||||
cy.uploadCompanyStamp();
|
||||
cy.contains("button", "Continue to verification").click();
|
||||
|
||||
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||||
@@ -305,6 +326,8 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||
});
|
||||
cy.drawSignature();
|
||||
// Staff counter-sign gates on a stamp too, same as the customer's modal.
|
||||
cy.uploadCompanyStamp();
|
||||
cy.get(".mantine-Modal-content")
|
||||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||
.click();
|
||||
@@ -337,10 +360,11 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
|
||||
it("operations approves the document and finalizes — contract fully executed", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||
|
||||
// The clearance review section lives behind its own tab on the detail page.
|
||||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||
// Clearance review + finalize live solely on the Operations "Clearance
|
||||
// Documents" hub — the contract detail page no longer embeds a tab for it.
|
||||
withContract((c) =>
|
||||
cy.visit(`/dashboard/contracts/clearance-documents/${c.id}`),
|
||||
);
|
||||
|
||||
// Approve the uploaded Cargo Manifest, then finalize.
|
||||
cy.contains("button", /Approve all/, { timeout: 30000 }).click();
|
||||
@@ -362,8 +386,12 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
// 20ft quantities must be even (pairs share a wagon).
|
||||
fill(/^Quantity/, "2");
|
||||
// Container contracts cover BOTH sizes, so the form renders a 20ft and a
|
||||
// 40ft line, each seeded quantity 1. Book 2 × 20ft (pairs share a wagon,
|
||||
// so the count must be even) and zero the 40ft line — otherwise its
|
||||
// default unit adds a third container-number input.
|
||||
fillNth(/^Quantity/, 0, "2");
|
||||
fillNth(/^Quantity/, 1, "0");
|
||||
|
||||
// One ISO container number per unit.
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||||
@@ -378,6 +406,7 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
// Intercity: no "Shipment day" picker — the ride-along note renders instead.
|
||||
cy.contains("Shipment day").should("not.exist");
|
||||
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
@@ -471,7 +500,12 @@ describe("intercity one-time journey: contract → booking → export train", {
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
|
||||
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||||
// Anchored: the reverse (import) route ends at this same yard, and
|
||||
// picking it would schedule against the import lead rule.
|
||||
cy.mantineSelect(
|
||||
/^Route$/,
|
||||
new RegExp(`^\\s*${ORIGIN_YARD}.*${PORT_YARD}\\s*$`),
|
||||
);
|
||||
|
||||
// Two days out, local datetime-local format.
|
||||
const departure = new Date(Date.now() + 2 * 86400000);
|
||||
|
||||
@@ -235,7 +235,14 @@ function createSchedule(trainCode: string, departure: Date) {
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
cy.mantineSelect(/^Route$/, /Nagad/);
|
||||
// Anchored at BOTH ends. /Nagad/ alone also matches the reverse (import)
|
||||
// route, which carries a different scheduling lead; anchoring only the
|
||||
// origin still collides with the other Mojo-origin corridor that runs on
|
||||
// through to Djibouti Port, whose schedules this spec's lookups ignore.
|
||||
cy.mantineSelect(
|
||||
/^Route$/,
|
||||
new RegExp(`^\\s*${ORIGIN_YARD}.*${PORT_YARD}\\s*$`),
|
||||
);
|
||||
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
@@ -275,6 +282,7 @@ function bookIntercityPair(contractRef: string, isoOffset: number) {
|
||||
});
|
||||
cy.contains("Shipment day").should("not.exist");
|
||||
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
@@ -451,6 +459,7 @@ describe(
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Quantity \(tons\)/, "130");
|
||||
pickShipmentDay(DEPART_W);
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
@@ -541,6 +550,7 @@ describe(
|
||||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||
});
|
||||
pickShipmentDay(DEPART_F);
|
||||
cy.fillCargoDescription();
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
|
||||
@@ -28,7 +28,11 @@ from (values
|
||||
('edr_gl_ethiopia', 'edr_gl_ethiopia'),
|
||||
('edr_gl_djibouti', 'edr_gl_djibouti'),
|
||||
('demo_user1', 'Demo User1'),
|
||||
('demo_user2', 'Demo User2')
|
||||
('demo_user2', 'Demo User2'),
|
||||
-- `super_admin` is the key isSuperAdmin() looks for. It is what lets this
|
||||
-- account bypass separation-of-duties rules (e.g. approving a rate it
|
||||
-- proposed), which several specs depend on.
|
||||
('super_admin', 'Super Admin')
|
||||
) v(key, name)
|
||||
where not exists (select 1 from iam.roles r where r.key = v.key);
|
||||
|
||||
@@ -64,20 +68,28 @@ from (values
|
||||
('gl-et@edr.local', 'gl_et', 'gl_et'),
|
||||
('gl-dj@edr.local', 'gl_dj', 'gl_dj'),
|
||||
('user@gmail.com', 'user', 'Demo User 1'),
|
||||
('user2@gmail.com', 'user2', 'Demo User 2')
|
||||
('user2@gmail.com', 'user2', 'Demo User 2'),
|
||||
-- Full-authority operator the corridor specs drive the customs/GL steps with
|
||||
-- (t1-close, risk, gate pass, second duty, import release). Nothing in the
|
||||
-- repo seeded it before — the suite silently relied on a hand-made row that
|
||||
-- only existed in a long-lived dev database, so a fresh stack failed 7 specs.
|
||||
('superadmin@tria.com', 'superadmin', 'Super Admin')
|
||||
) v(email, username, display)
|
||||
where not exists (select 1 from iam.users u where u.email = v.email);
|
||||
|
||||
-- ── Credentials ──────────────────────────────────────────────────────────────
|
||||
insert into iam.user_credentials (id, user_id, password, is_active)
|
||||
select gen_random_uuid(), u.id,
|
||||
case when u.email like '%@edr.local'
|
||||
case when u.email like '%@edr.local' or u.email = 'superadmin@tria.com'
|
||||
then '$argon2id$v=19$m=65536,t=3,p=4$JFEcHu4Kp55fsrVDPbHDPg$0NfnGzaE39T/qdmzte73oCkohC0Ri+f8DcrvAF4kyH4' -- password@tria
|
||||
else '$argon2id$v=19$m=65536,t=3,p=4$aBwVFf7I74pqSJqe9cBoig$gCIKa+6dCAb2X86G+0IjgPtil127cx6A6mwhvLj00Bw' -- 12345678
|
||||
end,
|
||||
true
|
||||
from iam.users u
|
||||
where (u.email like '%@edr.local' or u.email in ('user@gmail.com', 'user2@gmail.com'))
|
||||
where (
|
||||
u.email like '%@edr.local'
|
||||
or u.email in ('user@gmail.com', 'user2@gmail.com', 'superadmin@tria.com')
|
||||
)
|
||||
and not exists (select 1 from iam.user_credentials c where c.user_id = u.id);
|
||||
|
||||
-- ── User → role (staff under edr_freight, demo under demo_iam) ──────────────
|
||||
@@ -92,6 +104,7 @@ from (values
|
||||
('operation@edr.local', 'edr_operations_officer', 'edr_freight'),
|
||||
('gl-et@edr.local', 'edr_gl_ethiopia', 'edr_freight'),
|
||||
('gl-dj@edr.local', 'edr_gl_djibouti', 'edr_freight'),
|
||||
('superadmin@tria.com', 'super_admin', 'edr_freight'),
|
||||
('user@gmail.com', 'demo_user1', 'demo_iam'),
|
||||
('user2@gmail.com', 'demo_user2', 'demo_iam')
|
||||
) v(email, role_key, org_key)
|
||||
@@ -106,7 +119,7 @@ select gen_random_uuid(), true, 'pending', u.name, o.id, un.id, u.id
|
||||
from iam.users u
|
||||
join iam.organizations o on o.key = 'edr_freight'
|
||||
join iam.units un on un.key = 'edr_freight_app' and un.organization_id = o.id
|
||||
where u.email like '%@edr.local'
|
||||
where (u.email like '%@edr.local' or u.email = 'superadmin@tria.com')
|
||||
and not exists (select 1 from iam.employees e where e.user_id = u.id);
|
||||
|
||||
-- start_date must be set: the login query filters positions on
|
||||
@@ -122,7 +135,10 @@ from (values
|
||||
('marketer@edr.local', 'marketer'),
|
||||
('operation@edr.local', 'operation'),
|
||||
('gl-et@edr.local', 'ethiopian_gl'),
|
||||
('gl-dj@edr.local', 'djibouti_gl')
|
||||
('gl-dj@edr.local', 'djibouti_gl'),
|
||||
-- operations_chief carries the whole freight permission catalog, which is
|
||||
-- what the corridor specs need from this account.
|
||||
('superadmin@tria.com', 'operations_chief')
|
||||
) v(email, position_key)
|
||||
join iam.users u on u.email = v.email
|
||||
join iam.employees e on e.user_id = u.id
|
||||
|
||||
@@ -110,10 +110,93 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).click({ force: true });
|
||||
});
|
||||
// :visible — closed dropdowns can linger in the DOM, and two selects on one
|
||||
// page may list the same option text (e.g. the intercity wizard's origin +
|
||||
// destination both list every Ethiopian yard).
|
||||
cy.get('[role="option"]:visible').contains(option).click();
|
||||
// Scope to the OPEN listbox: closed dropdowns linger in the DOM, and two
|
||||
// selects on one page may list the same option text (e.g. the intercity
|
||||
// wizard's origin + destination both list every Ethiopian yard).
|
||||
//
|
||||
// The scope has to be the listbox rather than the options themselves. A long
|
||||
// list scrolls inside a max-height dropdown, and Cypress counts the clipped
|
||||
// rows as not-visible — matching on `[role="option"]:visible` silently drops
|
||||
// whatever sits past the fold (this hid the alphabetically-last trains).
|
||||
// force: the click still has to land on a row that needs scrolling to.
|
||||
cy.get('[role="listbox"]:visible')
|
||||
.last()
|
||||
.contains('[role="option"]', option)
|
||||
.click({ force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Fill the accept-contract modal's validity window. The modal used to offer a
|
||||
* dropdown of configured durations that defaulted to the first option; it now
|
||||
* takes explicit Start/End dates and pre-fills neither, so "Accept & start
|
||||
* approval" stays disabled until both are set.
|
||||
*
|
||||
* Mantine's DateInput parses typed text with its valueFormat, which defaults to
|
||||
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces.
|
||||
*/
|
||||
Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
|
||||
const start = new Date();
|
||||
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
const asInput = (d: Date) =>
|
||||
d.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
for (const [label, value] of [
|
||||
["Start date", start],
|
||||
["End date", end],
|
||||
] as const) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`)
|
||||
.clear({ force: true })
|
||||
.type(asInput(value), { force: true })
|
||||
// DateInput commits on blur; it also closes the calendar popover,
|
||||
// which would otherwise sit over the submit button.
|
||||
.blur();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach a company stamp in the open sign-contract modal. The stamp became a
|
||||
* REQUIRED field on signing — "Continue to verification" stays disabled without
|
||||
* one — and StampUpload only checks the MIME type and size before reading the
|
||||
* file as a data URL, so the smallest valid PNG is enough. The input is
|
||||
* `hidden` (a dropzone drives it), hence force.
|
||||
*/
|
||||
const STAMP_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
Cypress.Commands.add("uploadCompanyStamp", () => {
|
||||
cy.get('.mantine-Modal-content input[type="file"]')
|
||||
.first()
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from(STAMP_PNG, "base64"),
|
||||
fileName: "stamp.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{ force: true },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Fill the per-booking "Cargo description" on the new-shipment form. It is
|
||||
* REQUIRED for container shipments (it moved from the contract to the booking),
|
||||
* and the form validates through react-hook-form's handleSubmit — so leaving it
|
||||
* blank aborts silently: no price modal, no request, no error toast.
|
||||
* No-op for bulk shipments, which have no such field.
|
||||
*/
|
||||
Cypress.Commands.add("fillCargoDescription", (text = "Electronics") => {
|
||||
cy.get("body").then(($b) => {
|
||||
const field = $b.find('[placeholder^="e.g. Electronics"]');
|
||||
if (!field.length) return;
|
||||
cy.wrap(field.first()).clear({ force: true }).type(text, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
/** Type a 6-digit code into a Mantine PinInput. */
|
||||
@@ -168,6 +251,12 @@ declare global {
|
||||
getOtp(target: string): Chainable<string>;
|
||||
/** Open a Mantine Select by label, pick an option. */
|
||||
mantineSelect(label: string | RegExp, option: string | RegExp): Chainable<void>;
|
||||
/** Fill the accept-contract modal's Start/End validity dates. */
|
||||
acceptValidityWindow(days?: number): Chainable<void>;
|
||||
/** Attach the required company stamp in the sign-contract modal. */
|
||||
uploadCompanyStamp(): Chainable<void>;
|
||||
/** Fill the required per-booking cargo description (container only). */
|
||||
fillCargoDescription(text?: string): Chainable<void>;
|
||||
/** Fill a Mantine PinInput with a code. */
|
||||
typeOtp(code: string): Chainable<void>;
|
||||
/** Scribble on the signature-pad canvas inside the open modal. */
|
||||
|
||||
@@ -348,24 +348,44 @@ export interface IWagonMovement extends BaseEntity {
|
||||
* wagons); OCC staff later pick the physical wagons and execute the move.
|
||||
*/
|
||||
export enum WagonTransferRequestStatus {
|
||||
/** Awaiting OCC fulfilment. */
|
||||
/** Awaiting OCC fulfilment — nothing moved yet. */
|
||||
Pending = "PENDING",
|
||||
/** OCC picked the wagons and executed the transfer. */
|
||||
/**
|
||||
* Some of the asked-for wagons have moved and the request is still open. OCC
|
||||
* keeps sending more, any number at any time, until the full count is met.
|
||||
*/
|
||||
PartiallyFulfilled = "PARTIALLY_FULFILLED",
|
||||
/** The full requested count has moved. */
|
||||
Fulfilled = "FULFILLED",
|
||||
/** Requester or OCC withdrew it before fulfilment. */
|
||||
/**
|
||||
* OCC ended the request with fewer wagons than asked for — the source yard
|
||||
* has no more to give. The shortfall must be requested from another yard.
|
||||
*/
|
||||
ClosedShort = "CLOSED_SHORT",
|
||||
/** Requester or OCC withdrew it before anything moved. */
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Statuses where OCC can still move wagons against the request. */
|
||||
export const OPEN_WAGON_TRANSFER_STATUSES: WagonTransferRequestStatus[] = [
|
||||
WagonTransferRequestStatus.Pending,
|
||||
WagonTransferRequestStatus.PartiallyFulfilled,
|
||||
];
|
||||
|
||||
export interface IWagonTransferRequest extends BaseEntity {
|
||||
fromYardId: string;
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
|
||||
quantity: number;
|
||||
/** How many have actually moved so far — 0 until the first transfer. */
|
||||
fulfilledQuantity: number;
|
||||
status: WagonTransferRequestStatus;
|
||||
requestedByUserId?: string | null;
|
||||
fulfilledByUserId?: string | null;
|
||||
fulfilledAt?: string | null;
|
||||
/** Set when OCC ended the request short of the asked-for count. */
|
||||
closedShortAt?: string | null;
|
||||
/** Why the wagons are needed — required for new requests, shown on the OCC queue. */
|
||||
reason?: string | null;
|
||||
note?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user