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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-07 01:35:39 +03:00
committed by GitHub
44 changed files with 1824 additions and 202 deletions

View File

@@ -110,6 +110,7 @@ import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
if (!process.env.APPLICATION_NAME) {
process.env.APPLICATION_NAME = "freight";
@@ -273,6 +274,9 @@ if (!process.env.APPLICATION_NAME) {
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
// Feeds position-TYPE grants to the synchronous permission checks — without
// it, staff whose permissions live on their position type resolve to none.
PositionTypePermissionsCache,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -1,6 +1,9 @@
import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
);
});
});
/**
* The GL lockout regression: positions created through the admin UI keep their
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
* permissions. Without the type resolver those staff resolved to zero
* permissions, so every gated route rejected them — which is what kept GL
* officers out of their own clearance detail pages.
*/
describe('collectPermissionKeys — position-type grants', () => {
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
afterEach(() => {
setPositionTypePermissionResolver(() => []);
});
const glOfficer = {
roles: [],
permissions: [],
employee: {
position: {
permissions: [], // admin-created position carries NO direct grants
positionType: { key: 'commercial-global-logistics-(et)-officer' },
},
},
};
it('resolves permissions carried by the position type', () => {
setPositionTypePermissionResolver((key) =>
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
);
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
});
it('handles the array-shaped employee payload too', () => {
setPositionTypePermissionResolver(() => [CLEARANCE]);
const arrayShaped = {
roles: [],
permissions: [],
employee: [
{
positions: [
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
],
},
],
};
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
});
it('still rejects when neither the position nor its type grants it', () => {
setPositionTypePermissionResolver(() => []);
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
});
it('keeps direct position permissions working with no resolver installed', () => {
const direct = {
roles: [],
permissions: [],
employee: { position: { permissions: [{ key: CLEARANCE }] } },
};
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});

View File

@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
/**
* Permissions carried by a position TYPE rather than the position itself.
*
* The JWT snapshots only DIRECT position permissions, so type-level grants —
* which is where admin-created positions keep theirs — are absent from the
* token entirely. This resolver is installed at startup
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
* below can still see them. Left as a no-op resolver until then, which
* degrades to the old position-only behaviour rather than throwing.
*/
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
[];
export function setPositionTypePermissionResolver(
resolver: (positionTypeKey: string) => string[],
): void {
positionTypePermissionResolver = resolver;
}
/**
* Flat permission keys from JWT / session user: roles, position permissions,
* and the grants held by each position's TYPE.
*/
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
if (!positionType?.key) return;
for (const key of positionTypePermissionResolver(positionType.key)) {
keys.add(key);
}
};
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(pos.positionType);
}
}
return [...keys];
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(employee.position?.positionType);
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);

View File

@@ -0,0 +1,83 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { setPositionTypePermissionResolver } from './freight-permission.util';
/**
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
*
* A position type is the platform's notion of a role, and positions created
* through the admin UI carry their grants there rather than on the position
* itself. The JWT only ever snapshots DIRECT position permissions, so those
* grants are invisible to `collectPermissionKeys` — staff on such a position
* resolve to zero permissions and every permission-gated route rejects them.
*
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
* synchronous and sit on the request path, so the mapping is held in memory and
* refreshed periodically rather than queried per request. The dataset is tiny
* (tens of types, a few hundred rows), so a full reload is cheaper than any
* incremental scheme.
*/
@Injectable()
export class PositionTypePermissionsCache implements OnModuleInit {
private readonly logger = new Logger(PositionTypePermissionsCache.name);
/** position_type key → permission keys. Empty until the first load lands. */
private byPositionTypeKey = new Map<string, string[]>();
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
// in the admin UI takes up to one interval to reach the guards. Wire the
// grant mutation to call `refresh()` if that lag ever matters.
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async onModuleInit(): Promise<void> {
await this.refresh();
// Hand the lookup to the permission utils, whose checks are synchronous and
// therefore cannot query IAM themselves.
setPositionTypePermissionResolver((positionTypeKey) =>
this.get(positionTypeKey),
);
const timer = setInterval(() => {
void this.refresh();
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
// Never hold the process open for a cache refresh.
timer.unref?.();
}
/** Permission keys for a position-type key ([] when unknown/not loaded). */
get(positionTypeKey: string | undefined | null): string[] {
if (!positionTypeKey) return [];
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
}
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
async refresh(): Promise<void> {
try {
const rows: { position_type_key: string; permission_key: string }[] =
await this.dataSource.query(
`SELECT pt.key AS position_type_key, perm.key AS permission_key
FROM iam.position_type_permissions ptp
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
);
const next = new Map<string, string[]>();
for (const row of rows) {
if (!row.position_type_key || !row.permission_key) continue;
const keys = next.get(row.position_type_key);
if (keys) keys.push(row.permission_key);
else next.set(row.position_type_key, [row.permission_key]);
}
this.byPositionTypeKey = next;
} catch (err) {
// iam schema unreachable — keep serving the previous snapshot rather than
// dropping every type-derived permission and locking staff out.
this.logger.warn(
`Position-type permission refresh failed: ${(err as Error).message}`,
);
}
}
}

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
permissions: positionPermissions,
positionType,
},
]
@@ -65,7 +112,15 @@ export class FreightMeService {
]
: [];
const permissionKeys = collectPermissionKeys(user);
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return {
id: user.id,

View File

@@ -90,6 +90,21 @@ export class BookingInvoiceService {
return this.billing.generateInvoice(input);
}
/**
* Cancel the booking's open PREPAID invoice, if any — used when a
* changes-requested resubmit restates the cargo, so the re-priced booking can
* be re-invoiced. Throws when the invoice already has payments recorded
* (cargo must not change out from under recorded money).
*/
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
);
if (existing) await this.billing.cancelInvoice(existing.id);
}
/**
* React to a booking invoice being paid — the settlement branch point. Per-type
* reactions live here (not in the payment process): each invoice type advances

View File

@@ -429,6 +429,20 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer self-service cancel, allowed only before payment — no fee.
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
* take the plain cancel path (open invoices expired, nothing reserved yet).
* Anything past payment falls through to cancel()'s status assertion.
*/
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "SELECTED_FOR_BATCH") {
return this.cancelHold(bookingId, reason);
}
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -978,6 +992,15 @@ export class BookingTransitionService {
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
// legitimate "let the system choose".
if (isExportTrain && !requestedId) {
throw new BadRequestException(
"Select a train for the chosen shipment day.",
);
}
const scheduledBooking = {
...booking,
scheduledDate: date,

View File

@@ -496,6 +496,22 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/wagons')
@ApiOperation({
summary:
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
})
async wagonAllocations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.bookingsService.wagonAllocations(id);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@@ -1335,6 +1351,19 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/customer-cancel")
@ApiOperation({
summary:
"Customer cancels their own booking before payment — no cancellation fee",
})
async customerCancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.customerCancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@ApiOperation({
summary:

View File

@@ -339,6 +339,63 @@ export class BookingsService {
};
}
/**
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
* join chain as the carriage acceptance sheet, but structured (containers as
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
return this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
wt.code AS "wagonTypeCode",
wt.tare_weight_tons AS "tareWeightTons",
tsw.capacity_tons AS "capacityTons",
tsw.length_meters AS "lengthMeters",
a.allocated_weight_tons AS "allocatedWeightTons",
a.load_type AS "loadType",
a.status AS "status",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "originStation",
sd.label AS "destinationStation",
bl.cargo_description AS "bulkCargoDescription",
bl.quantity AS "bulkQuantity",
COALESCE(
json_agg(
json_build_object(
'containerNumber', ci.container_number,
'sealNumber', ci.seal_number,
'positionOnWagon', ci.position_on_wagon,
'grossWeightTons', ci.gross_weight_tons
) ORDER BY ci.position_on_wagon, ci.container_number
) FILTER (WHERE ci.id IS NOT NULL),
'[]'
) AS "containers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
bl.cargo_description, bl.quantity
ORDER BY tsw.sequence_no`,
[bookingId],
);
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -1425,7 +1482,46 @@ export class BookingsService {
tradeDirection,
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -692,11 +692,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1882,9 @@ export class ContractBookingService {
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
@@ -2024,6 +2046,7 @@ export class ContractBookingService {
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>

View File

@@ -1124,8 +1124,11 @@ export class ContractsController {
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto);
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')

View File

@@ -959,7 +959,20 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
]),
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset.
director: dedupe([
...ROLE_PERMISSION_PRESETS.director,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
]),
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),

View File

@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
positionType?: {
id?: string;
key?: string;
name?: LocaleText;
/** Grants held by the TYPE — where admin-created positions keep theirs. */
permissions?: AuthPermission[];
} | null;
}
interface AuthEmployeeRecord {

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
/** Completion-form route for editing the cargo before resubmitting —
* rendered only for resubmit-capable users when provided. */
editHref?: string;
onResubmitted?: () => void;
}
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
note,
scheduledDate,
canResubmit,
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
>
Resubmit to Operations
</Button>
{editHref ? (
<Button
component={Link}
to={editHref}
variant="default"
radius="md"
size="sm"
leftSection={<Pencil size={15} />}
>
Edit cargo & resubmit
</Button>
) : null}
</Group>
) : null}
</Stack>

View File

@@ -961,7 +961,7 @@ export default function GlCreateBookingForm() {
// modal falls back to the contract unit-rate estimate while it loads.
const validateShipmentMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
contractsService.validateShipment(id ?? "", dto),
contractsService.validateShipment(id ?? "", dto, completeBookingId),
});
const validation = validateShipmentMutation.data ?? null;

View File

@@ -348,6 +348,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
// Positions created through the admin UI keep their grants on the
// position TYPE, not the position — miss these and such staff resolve to
// zero permissions and every gated route rejects them. `/api/me` folds
// them into the position's permission list, but older payloads may still
// carry them separately.
for (const p of pos.positionType?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];

View File

@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
note={clearance.linkedBookingReviewNote}
scheduledDate={clearance.linkedBookingScheduledDate}
canResubmit={canResubmitBooking}
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
onResubmitted={() => {
void refetch();
void refetchContract();

View File

@@ -450,6 +450,35 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{/* A contract resting in APPROVED means the automatic PDF generation on
final approval failed — on success it moves straight to
CONTRACT_READY. Offer the manual retry. */}
{contract.status === "APPROVED" ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Contract document was not generated"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
All approvals are complete, but generating the contract PDF
failed. Retry the generation below.
</Text>
<Button
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Regenerate contract
</Button>
</Stack>
</Alert>
) : null}
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"

View File

@@ -916,10 +916,10 @@ export default function TrainScheduleV2DetailPage() {
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
{schedule.trainNumber}
</Badge>
{schedule.train?.trainName ? (
<Text fw={700} style={{ color: "#0f172a" }}>
{schedule.train.trainName}
</Text>
) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
@@ -927,6 +927,51 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
{/* Voyage (train) number and trade direction — the two things
operations identify a run by, so they read at a glance
rather than as small badges among the rest. */}
<Group gap="lg" align="center" wrap="wrap">
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.trainNumber}
</Text>
</Box>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Direction
</Text>
<Text
fw={800}
lh={1.1}
tt="uppercase"
style={{
fontSize: 32,
letterSpacing: 0.5,
color:
schedule.direction === "IMPORT"
? "#2E5B96"
: schedule.direction === "EXPORT"
? "#0A6F4D"
: "#0f172a",
}}
>
{schedule.direction}
</Text>
</Box>
) : null}
</Group>
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",

View File

@@ -673,8 +673,16 @@ export const contractsService = {
validateShipment: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own containers from
// the same-train clash check.
excludeBookingId?: string,
) =>
postContract<ShipmentValidation>(C.VALIDATE_SHIPMENT(id), payload),
postContract<ShipmentValidation>(
excludeBookingId
? `${C.VALIDATE_SHIPMENT(id)}?bookingId=${excludeBookingId}`
: C.VALIDATE_SHIPMENT(id),
payload,
),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {

View File

@@ -1,18 +1,30 @@
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Send, XCircle } from "lucide-react";
import {
AlertCircle,
CalendarDays,
ChevronRight,
Package,
Pencil,
Send,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
@@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractInfoCard } from "./components/ContractInfoCard";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/** Row linking straight to one section of the edit-booking form. */
function EditLink({
icon,
title,
description,
onClick,
}: {
icon: ReactNode;
title: string;
description: string;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="sm"
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
width: "100%",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Box mt={2} c="#0A6F4D">
{icon}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={13.5} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
{description}
</Text>
</Box>
<Box mt={2} c="#9AA8B5">
<ChevronRight size={16} />
</Box>
</Group>
</UnstyledButton>
);
}
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
@@ -64,8 +118,9 @@ export function ChangesRequestedView({
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -76,17 +131,21 @@ export function ChangesRequestedView({
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
actions={
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelDialogOpen(true)}
/>
}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
<StatusHero booking={booking}>
{booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
<ActionRequiredBanner title="Review the requested changes, update the booking if needed, then resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
@@ -101,6 +160,41 @@ export function ChangesRequestedView({
<ContractInfoCard booking={booking} />
<SectionCard>
<CardTitle>Fix your booking</CardTitle>
<Text fz="12.5px" c="#6B7C8E" mt={4} mb="md">
Staff asked for changes on this booking. Update whatever needs
fixing below, then resubmit for review the booking stays in
place, no need to start over.
</Text>
<Stack gap={8}>
<EditLink
icon={<Package size={16} />}
title="Cargo & containers"
description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=cargo`)
}
/>
<EditLink
icon={<CalendarDays size={16} />}
title="Schedule date"
description="Pick a different departure day — only days with an open schedule on your route can be selected."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=schedule`)
}
/>
<EditLink
icon={<Pencil size={16} />}
title="Route, service & other details"
description="Change the origin or destination yard, service type, trucking options or notes."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=service`)
}
/>
</Stack>
</SectionCard>
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>

View File

@@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import {
bookingsService,
type SubmitBookingResponse,
} from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -116,8 +119,9 @@ export function DraftBookingView({
});
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -157,17 +161,21 @@ export function DraftBookingView({
<PageHeader
booking={booking}
actions={
<Group gap={8} wrap="nowrap">
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel"
onClick={() => setCancelDialogOpen(true)}
/>
</Group>
}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors

View File

@@ -1,15 +1,21 @@
import { Group, Tabs } from "@mantine/core";
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import {
Clock,
CreditCard,
FileText,
LayoutGrid,
Package,
TrainFront,
Truck,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -28,6 +34,7 @@ import { MileSummaryCard } from "./components/MileSummaryCard";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
ActionRequiredBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
@@ -41,10 +48,32 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { WagonsTab } from "./components/WagonsTab";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
// Pre-payment statuses the customer may self-cancel from this view (free of
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
// simply be deleted; anything at or past payment must go through support.
const CUSTOMER_CANCELLABLE_STATUSES = [
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"PENDING_APPROVAL",
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
"SELECTED_FOR_BATCH",
];
const cancelErrorMessage = (error: unknown) => {
const data = (
error as { response?: { data?: { message?: string | string[] } } }
)?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return "Could not cancel the booking. Please try again.";
};
export function ReadonlyBookingView({
booking,
onBookingUpdated,
@@ -71,6 +100,23 @@ export function ReadonlyBookingView({
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const [cancelOpen, setCancelOpen] = useState(false);
const cancelMutation = useMutation({
mutationFn: () => bookingsService.customerCancel(booking.id),
onSuccess: () => {
setCancelOpen(false);
toast.success(
"Your booking has been cancelled — no cancellation fee was charged.",
{ duration: 6000 },
);
onBookingUpdated?.();
},
onError: (e) => toast.error(cancelErrorMessage(e)),
});
const canCancel =
booking.paymentStatus !== "PAID" &&
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
// enters batch selection. A one-time booking can only pay once it's been
@@ -117,6 +163,9 @@ export function ReadonlyBookingView({
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
"OPERATION_REQUESTED",
// Operations returned the order — same card hosts the pick-a-new-day +
// resubmit flow.
"OPERATION_CHANGES_REQUESTED",
].includes(status);
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
@@ -124,13 +173,16 @@ export function ReadonlyBookingView({
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
// Wagons exist only after payment puts the booking on a train; before that
// the tab would always be an empty state, so it stays hidden.
const showWagonsTab = booking.paymentStatus === "PAID" && !isNegative(status);
return (
<PageShell>
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown)) && (
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -143,13 +195,17 @@ export function ReadonlyBookingView({
onClick={pay.open}
/>
)}
{canCancel && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
}}
/>
{isNegative(status) ? (
@@ -182,7 +238,14 @@ export function ReadonlyBookingView({
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
<StatusHero booking={booking}>
{status === "OPERATION_CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner title="Operations requested changes — pick a new shipment day and resubmit.">
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
)}
{showPairedNotice && <ConsolidationPairedNotice />}
@@ -210,6 +273,11 @@ export function ReadonlyBookingView({
<Tabs.Tab value="cargo" leftSection={<Package size={15} />}>
Cargo
</Tabs.Tab>
{showWagonsTab && (
<Tabs.Tab value="wagons" leftSection={<TrainFront size={15} />}>
Wagons
</Tabs.Tab>
)}
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
@@ -269,6 +337,12 @@ export function ReadonlyBookingView({
<CargoTab booking={booking} />
</Tabs.Panel>
{showWagonsTab && (
<Tabs.Panel value="wagons">
<WagonsTab bookingId={booking.id} />
</Tabs.Panel>
)}
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid
@@ -313,6 +387,52 @@ export function ReadonlyBookingView({
bill={pay.bill}
onConfirm={pay.confirm}
/>
<Modal
opened={cancelOpen}
onClose={() => setCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. Since you haven&apos;t paid yet,{" "}
<Text span fw={700}>
no cancellation fee
</Text>{" "}
will be charged
{status === "SELECTED_FOR_BATCH"
? ", and your reserved wagon space will be released immediately"
: ""}
. This cannot be undone.
</Text>
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
loading={cancelMutation.isPending}
onClick={() => cancelMutation.mutate()}
>
Cancel booking
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -51,7 +51,12 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
}
const summary =
status === "CLEARANCE_READY" ? (
status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
Operations returned this order for changes. Update the booking details,
pick a new shipment day and resubmit.
</Alert>
) : status === "CLEARANCE_READY" ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
{`${
booking.customsClearingEnabled
@@ -103,7 +108,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
{isBookAction
{status === "OPERATION_CHANGES_REQUESTED"
? "Use “Change booking” to update the details and pick a new shipment day."
: isBookAction
? "Use “Book” to enter the cargo details and schedule your shipment."
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
</Text>

View File

@@ -1,14 +1,5 @@
import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core";
import {
ArrowDownLeft,
ArrowUpRight,
Edit2,
FileText,
HelpCircle,
MoreHorizontal,
RefreshCw,
XCircle,
} from "lucide-react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -20,22 +11,12 @@ import {
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
onViewContract?: () => void;
onCancel?: () => void;
onEdit?: () => void;
onSupport?: () => void;
onRebook?: () => void;
}
export function PageHeader({
booking,
actions,
menuActions,
}: {
booking: Freight.IBooking;
actions?: ReactNode;
menuActions?: PageHeaderMenuActions;
}) {
const status = booking.status as string;
const negative = isNegative(status);
@@ -47,8 +28,6 @@ export function PageHeader({
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const isExport = booking.tradeDirection === "EXPORT";
const hasMenu = menuActions && Object.values(menuActions).some(Boolean);
return (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
@@ -83,66 +62,6 @@ export function PageHeader({
<Group gap={8} wrap="nowrap" align="center">
{actions}
{hasMenu && (
<Menu shadow="md" radius={12} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="default"
size={42}
radius={10}
aria-label="More options"
>
<MoreHorizontal size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={210}>
{menuActions!.onViewContract && (
<Menu.Item
leftSection={<FileText size={15} />}
onClick={menuActions!.onViewContract}
>
View contract
</Menu.Item>
)}
{menuActions!.onEdit && (
<Menu.Item
leftSection={<Edit2 size={15} />}
onClick={menuActions!.onEdit}
>
Edit
</Menu.Item>
)}
{menuActions!.onSupport && (
<Menu.Item
leftSection={<HelpCircle size={15} />}
onClick={menuActions!.onSupport}
>
Contact customer support
</Menu.Item>
)}
{menuActions!.onRebook && (
<Menu.Item
leftSection={<RefreshCw size={15} />}
onClick={menuActions!.onRebook}
>
Rebook similar schedule
</Menu.Item>
)}
{menuActions!.onCancel && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<XCircle size={15} />}
onClick={menuActions!.onCancel}
>
Cancel booking
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
);
@@ -154,6 +73,7 @@ export function HeaderButton({
onClick,
dark,
green,
red,
disabled,
}: {
label: string;
@@ -161,6 +81,7 @@ export function HeaderButton({
onClick?: () => void;
dark?: boolean;
green?: boolean;
red?: boolean;
disabled?: boolean;
}) {
return (
@@ -169,14 +90,14 @@ export function HeaderButton({
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
variant={green || dark ? "filled" : red ? "outline" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
color: green || dark ? "#fff" : red ? undefined : "#10202F",
},
}}
>

View File

@@ -0,0 +1,445 @@
import { Box, Group, SimpleGrid, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Container,
Gauge,
MapPin,
Package,
Route,
Scale,
TrainFront,
TrainTrack,
} from "lucide-react";
import type { ReactNode } from "react";
import {
bookingsService,
type BookingWagonAllocation,
} from "@/services/bookings.service";
import { fmtDate, fmtWeight } from "../utils";
import { CardTitle, SectionCard } from "./layout";
// Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page.
const STATUS_TONES: Record<
BookingWagonAllocation["status"],
{ bg: string; color: string; label: string }
> = {
PLANNED: { bg: "#F1F4F7", color: "#475569", label: "Planned" },
RESERVED: { bg: "#FFFBEB", color: "#92400E", label: "Reserved" },
LOADED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Loaded" },
DEPARTED: { bg: "#EAF1FE", color: "#1E40AF", label: "Departed" },
};
function StatusPill({ status }: { status: BookingWagonAllocation["status"] }) {
const tone = STATUS_TONES[status] ?? STATUS_TONES.PLANNED;
return (
<Text
component="span"
fz={11}
fw={700}
px={9}
py={3}
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
>
{tone.label}
</Text>
);
}
function StatTile({
icon,
label,
value,
sub,
}: {
icon: ReactNode;
label: string;
value: string;
sub?: string;
}) {
return (
<Box
p={14}
style={{
borderRadius: 12,
border: "1px solid #E6ECF2",
backgroundColor: "#FAFCFE",
}}
>
<Group gap={6} align="center" mb={6} c="#6B7C8E">
{icon}
<Text fz="11px" fw={700} tt="uppercase" style={{ letterSpacing: "0.05em" }}>
{label}
</Text>
</Group>
<Text fz={18} fw={800} c="#10202F" truncate>
{value}
</Text>
{sub && (
<Text fz={12} c="#9AA8B5" mt={2}>
{sub}
</Text>
)}
</Box>
);
}
/** Little consist strip: locomotive + one box per wagon, in marshalling order. */
function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
return (
<Box style={{ overflowX: "auto" }} pb={4}>
<Group gap={5} wrap="nowrap" align="flex-end">
<Box
px={10}
py={8}
style={{
borderRadius: "10px 4px 4px 10px",
backgroundColor: "#10202F",
color: "white",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
}}
>
<TrainFront size={16} />
<Text fz={11} fw={800}>
LOCO
</Text>
</Box>
{wagons.map((w) => (
<Tooltip
key={w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status
}`}
withArrow
>
<Box
px={10}
py={8}
ta="center"
style={{
borderRadius: 6,
border: "1.5px solid #C9D6E2",
backgroundColor: STATUS_TONES[w.status]?.bg ?? "#F1F4F7",
flexShrink: 0,
minWidth: 64,
cursor: "default",
}}
>
<Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo}
</Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"}
</Text>
</Box>
</Tooltip>
))}
</Group>
</Box>
);
}
function LoadBar({ allocated, capacity }: { allocated: number; capacity: number }) {
const pct = capacity > 0 ? Math.min(100, Math.round((allocated / capacity) * 100)) : 0;
return (
<Box>
<Group justify="space-between" mb={4}>
<Text fz={11.5} fw={700} c="#6B7C8E">
Load
</Text>
<Text fz={11.5} fw={800} c="#10202F">
{fmtWeight(allocated)}
{capacity > 0 ? ` / ${fmtWeight(capacity)} · ${pct}%` : ""}
</Text>
</Group>
<Box style={{ height: 6, borderRadius: 999, backgroundColor: "#EDF2F7" }}>
<Box
style={{
height: 6,
width: `${pct}%`,
borderRadius: 999,
backgroundColor: pct >= 95 ? "#B45309" : "#0A6F4D",
transition: "width 300ms ease",
}}
/>
</Box>
</Box>
);
}
const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) {
const allocated = Number(wagon.allocatedWeightTons || 0);
const capacity = Number(wagon.capacityTons || 0);
const containers = wagon.containers ?? [];
return (
<SectionCard>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap={10} align="center" wrap="nowrap">
<Box
style={{
width: 40,
height: 40,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#10202F",
color: "white",
flexShrink: 0,
}}
>
<Text fz={9} fw={700} c="#9AA8B5" lh={1}>
WAGON
</Text>
<Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo}
</Text>
</Box>
<Box>
<Text fz={16} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{wagon.wagonNumber ?? "Not yet assigned"}
</Text>
<Text fz={12} c="#9AA8B5">
{wagon.wagonType ?? "Wagon type pending"}
{wagon.wagonTypeCode && wagon.wagonType !== wagon.wagonTypeCode
? ` · ${wagon.wagonTypeCode}`
: ""}
</Text>
</Box>
</Group>
<StatusPill status={wagon.status} />
</Group>
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
{Number(wagon.tareWeightTons) > 0 && (
<Group gap={5}>
<Scale size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
Tare {fmtWeight(Number(wagon.tareWeightTons))}
</Text>
</Group>
)}
{Number(wagon.lengthMeters) > 0 && (
<Group gap={5}>
<Route size={12} color="#9AA8B5" />
<Text fz={12} c="#475569">
{Number(wagon.lengthMeters)} m
</Text>
</Group>
)}
<Group gap={5}>
{wagon.loadType === "BULK" ? (
<Package size={12} color="#9AA8B5" />
) : (
<Container size={12} color="#9AA8B5" />
)}
<Text fz={12} c="#475569">
{wagon.loadType === "BULK" ? "Bulk load" : "Container load"}
</Text>
</Group>
</Group>
{wagon.loadType === "BULK" && (wagon.bulkCargoDescription || wagon.bulkQuantity) && (
<Box
p={10}
style={{ borderRadius: 10, backgroundColor: "#FAFCFE", border: "1px solid #EDF2F7" }}
>
<Text fz={12.5} fw={700} c="#10202F">
{wagon.bulkCargoDescription ?? "Bulk cargo"}
</Text>
{Number(wagon.bulkQuantity) > 0 && (
<Text fz={12} c="#9AA8B5">
Quantity: {Number(wagon.bulkQuantity).toLocaleString()}
</Text>
)}
</Box>
)}
{containers.length > 0 && (
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th style={th}>Container no.</Table.Th>
<Table.Th style={th}>Seal no.</Table.Th>
<Table.Th style={th}>Gross wt.</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c, i) => (
<Table.Tr key={c.containerNumber ?? i}>
<Table.Td>
<Text fz={12.5} fw={700} c="#10202F" style={{ fontFamily: "monospace" }}>
{c.containerNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{c.sealNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={12.5} c="#475569">
{Number(c.grossWeightTons) > 0
? fmtWeight(Number(c.grossWeightTons))
: "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
)}
</SectionCard>
);
}
/**
* "Wagons" tab: the customer's view of their allocated wagons once the paid
* booking has been placed on a train — consist strip in marshalling order,
* per-wagon load/containers, and the train's route summary.
*/
export function WagonsTab({ bookingId }: { bookingId: string }) {
const { data: wagons, isLoading } = useQuery({
queryKey: ["booking-wagons", bookingId],
queryFn: () => bookingsService.getWagons(bookingId),
enabled: !!bookingId,
});
if (isLoading) {
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<Skeleton height={140} radius={16} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
<Skeleton height={220} radius={16} />
<Skeleton height={220} radius={16} />
</SimpleGrid>
</div>
);
}
if (!wagons?.length) {
return (
<SectionCard style={{ maxWidth: 980 }}>
<Group gap={12} align="center">
<Box
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#F1F4F7",
color: "#6B7C8E",
}}
>
<TrainTrack size={22} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
No wagons allocated yet
</Text>
<Text fz={13} c="#9AA8B5">
Your wagons will appear here once the shipment is placed on a
train after payment.
</Text>
</Box>
</Group>
</SectionCard>
);
}
const first = wagons[0];
const totalAllocated = wagons.reduce(
(s, w) => s + Number(w.allocatedWeightTons || 0),
0,
);
const totalCapacity = wagons.reduce((s, w) => s + Number(w.capacityTons || 0), 0);
const containerCount = wagons.reduce((s, w) => s + (w.containers?.length ?? 0), 0);
const utilization =
totalCapacity > 0 ? Math.round((totalAllocated / totalCapacity) * 100) : null;
return (
<div className="flex flex-col gap-6" style={{ maxWidth: 980 }}>
<SectionCard>
<Group justify="space-between" align="flex-start" mb="md" wrap="wrap">
<Group gap={10} align="center">
<Box
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "#E8F5EF",
color: "#0A6F4D",
}}
>
<TrainFront size={18} />
</Box>
<Box>
<Text fz={15} fw={800} c="#10202F">
{first.trainNumber ? `Train ${first.trainNumber}` : "Your train"}
</Text>
<Group gap={5} align="center">
<MapPin size={11} color="#9AA8B5" />
<Text fz={12} c="#9AA8B5">
{first.originStation ?? "—"} {first.destinationStation ?? "—"}
{first.departureAt ? ` · departs ${fmtDate(first.departureAt)}` : ""}
</Text>
</Group>
</Box>
</Group>
<CardTitle>Your wagons on this train</CardTitle>
</Group>
<ConsistStrip wagons={wagons} />
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing={10} mt="md">
<StatTile
icon={<TrainTrack size={13} />}
label="Wagons"
value={`${wagons.length}`}
sub="allocated to you"
/>
<StatTile
icon={<Scale size={13} />}
label="Allocated weight"
value={fmtWeight(totalAllocated)}
/>
<StatTile
icon={<Container size={13} />}
label="Containers"
value={containerCount ? `${containerCount}` : "—"}
sub={containerCount ? "loaded on wagons" : undefined}
/>
<StatTile
icon={<Gauge size={13} />}
label="Utilization"
value={utilization != null ? `${utilization}%` : "—"}
sub="of wagon capacity"
/>
</SimpleGrid>
</SectionCard>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard key={w.sequenceNo} wagon={w} />
))}
</SimpleGrid>
</div>
);
}

View File

@@ -85,7 +85,12 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
return (
<Stack gap={0}>
{isReady ? (
{status === "OPERATION_CHANGES_REQUESTED" ? (
<Alert color="yellow" radius="md" icon={<Clock size={18} />} mb="md">
Operations returned this order for changes. Review their note, pick a
new shipment day below and resubmit.
</Alert>
) : isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."

View File

@@ -89,6 +89,22 @@ function actionByStatus(booking: ActionBooking): BookingNextAction | null {
label: "Schedule & proceed",
title: "Schedule your shipment",
};
case "OPERATION_CHANGES_REQUESTED":
// Contract bookings reopen the full completion form (cargo + shipment
// day, prefilled from the booking) — same page as the initial booking.
// Contract-less bookings keep the in-place day-picker modal.
return booking.contractId
? {
kind: "BOOK",
label: "Change booking",
title: "Change your booking",
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
}
: {
kind: "SCHEDULE_OPERATION",
label: "Choose day & resubmit",
title: "Resubmit your shipment",
};
default:
return null;
}

View File

@@ -98,7 +98,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
[clearance],
);
const isReady = status === "CLEARANCE_READY";
// OPERATION_CHANGES_REQUESTED re-opens the same pick-a-day flow: the
// customer resubmits via the same clearance/proceed endpoint.
const isReady =
status === "CLEARANCE_READY" || status === "OPERATION_CHANGES_REQUESTED";
// Bare initiated instance: created with no cargo and no price; completion
// (cargo + shipment day + window check) happens on the full booking form.
const isBareInstance =

View File

@@ -38,6 +38,7 @@ import {
CheckCircle2,
ChevronLeft,
FileDown,
FileText,
FileUp,
Flame,
MapPin,
@@ -59,6 +60,7 @@ import {
contractsService,
type ShipmentValidation,
} from "@/services/contracts.service";
import { downloadStoredFile } from "@/services/files.service";
import {
SelectField,
StepCard,
@@ -245,6 +247,127 @@ function bulkUnitOfMeasure(
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
/**
* Prefill for a changes-requested resubmit: the booking's persisted cargo,
* currency and route become the form's starting values so the customer edits
* what exists instead of retyping it. The shipment day is deliberately left
* empty — a new day must be picked.
*/
function mapBookingToShipmentValues(
booking: Freight.IBooking,
contract: Freight.IContract,
): Partial<ShipmentFormInputValues> {
const b = booking as unknown as {
cargoFreeText?: string | null;
contractRouteId?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bulkHazardousQuantity?: number | string | null;
bulkReeferQuantity?: number | string | null;
bookingContainers?: Array<{
quantity?: number;
hazardousQuantity?: number | string | null;
reeferQuantity?: number | string | null;
returnQuantity?: number | string | null;
containerType?: { sizeFt?: number | null } | null;
units?: Array<{
containerNumber?: string;
sealNumber?: string | null;
vgmTons?: number | string;
isHazardous?: boolean;
isReefer?: boolean;
isReturn?: boolean;
}>;
}>;
};
const values: Partial<ShipmentFormInputValues> = {
paymentCurrency: booking.paymentCurrency === "ETB" ? "ETB" : "USD",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
const rows = b.bookingContainers ?? [];
const lineFor = (size: "20ft" | "40ft") => {
const bc = rows.find(
(r) => (r.containerType?.sizeFt === 40 ? "40ft" : "20ft") === size,
);
return {
containerSize: size,
quantity: String(bc?.quantity ?? 0),
hazardousQuantity: String(Number(bc?.hazardousQuantity ?? 0)),
reeferQuantity: String(Number(bc?.reeferQuantity ?? 0)),
returnQuantity: String(Number(bc?.returnQuantity ?? 0)),
units: (bc?.units ?? []).map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: String(Number(u.vgmTons ?? 0)),
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
})),
};
};
const sizes = (contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
? String(Number(b.bulkTotalWeightTons))
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
}
return values;
}
/** Read-only list of the booking's already-uploaded documents (resubmit view). */
function UploadedDocumentsCard({ booking }: { booking: Freight.IBooking }) {
const files =
(booking as unknown as { files?: Array<{ id: string; name: string }> })
.files ?? [];
if (!files.length) return null;
return (
<Paper withBorder radius="lg" p="lg">
<Group gap={8} mb={4}>
<FileText size={16} />
<Text fw={700} fz="sm">
Your uploaded documents
</Text>
</Group>
<Text fz={12.5} c="dimmed" mb="sm">
These stay attached to the booking no need to upload them again.
</Text>
<Stack gap={6}>
{files.map((f) => (
<Group key={f.id} justify="space-between" wrap="nowrap">
<Text fz={13} truncate>
{f.name}
</Text>
<ActionIcon
variant="default"
radius="md"
onClick={() => void downloadStoredFile(f.id, f.name)}
aria-label={`Download ${f.name}`}
>
<FileDown size={15} />
</ActionIcon>
</Group>
))}
</Stack>
</Paper>
);
}
function NewShipmentBookingForm({
contract,
contractId,
@@ -284,6 +407,10 @@ function NewShipmentBookingForm({
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
// Export completion locks onto a specific train — the pick is required
// (mirrors the ScheduleStep picker's visibility).
requiresTrain:
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
}),
),
mode: "onChange",
@@ -302,6 +429,34 @@ function NewShipmentBookingForm({
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
// the form from it and shows the operations note + uploaded documents.
const { data: completeBooking } = useQuery(
api.bookings.get.queryOptions({
input: { id: completeBookingId! },
enabled: Boolean(completeBookingId),
}),
);
const isResubmit = Boolean(
completeBooking &&
["OPERATION_CHANGES_REQUESTED", "EXPIRED"].includes(
completeBooking.status as string,
) &&
(((completeBooking as unknown as { bookingContainers?: unknown[] })
.bookingContainers?.length ?? 0) > 0 ||
Number(completeBooking.cargoTotalWeightVgm ?? 0) > 0),
);
const prefilledRef = useRef(false);
useEffect(() => {
if (!isResubmit || prefilledRef.current || !completeBooking) return;
prefilledRef.current = true;
form.reset({
...form.getValues(),
...mapBookingToShipmentValues(completeBooking, contract),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isResubmit]);
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -328,7 +483,12 @@ function NewShipmentBookingForm({
// price modal opens so re-reviewing after an edit re-checks.
const validateMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.validateShipment.call({ id: contractId, dto }),
api.contracts.validateShipment.call({
id: contractId,
dto,
// Resubmit preview must not clash with this booking's own containers.
excludeBookingId: completeBookingId,
}),
});
function buildDto(
@@ -484,12 +644,16 @@ function NewShipmentBookingForm({
style={{ letterSpacing: "-0.01em" }}
>
{completeBookingId
? "Complete Your Booking"
? isResubmit
? "Change Your Booking"
: "Complete Your Booking"
: "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{completeBookingId
? `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
? isResubmit
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
: `Book a shipment against contract ${contract.reference}.`}
</Text>
</Box>
@@ -531,6 +695,16 @@ function NewShipmentBookingForm({
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
{isResubmit && completeBooking?.latestChangeRequestNote && (
<Alert
color="yellow"
icon={<AlertCircle size={16} />}
radius="md"
title="Operations requested changes"
>
<Text size="sm">{completeBooking.latestChangeRequestNote}</Text>
</Alert>
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
@@ -543,6 +717,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
{isResubmit && completeBooking && (
<UploadedDocumentsCard booking={completeBooking} />
)}
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
@@ -590,7 +767,7 @@ function NewShipmentBookingForm({
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
@@ -1179,12 +1356,23 @@ function ScheduleStep({
</Text>
)}
{isExportPick && scheduledDate ? (
<>
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={selectedTrainId ?? ""}
onChange={(id) => form.setValue("trainScheduleId", id)}
onChange={(id) =>
form.setValue("trainScheduleId", id, {
shouldValidate: true,
})
}
/>
{form.formState.errors.trainScheduleId?.message && (
<Text fz="xs" c="red" mt={6}>
{String(form.formState.errors.trainScheduleId.message)}
</Text>
)}
</>
) : null}
</Box>
)}

View File

@@ -30,6 +30,11 @@ export interface ShipmentValidationContext {
* staff pick later, so no shipment day is chosen. Defaults to true.
*/
requiresDate?: boolean;
/**
* EXPORT rail completion: the shipment must ride a specific train the
* customer picks for the chosen day. Defaults to false.
*/
requiresTrain?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
@@ -102,6 +107,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
// Train is only pickable once a day is chosen — the day error covers the
// no-date case, so don't stack a second error on an invisible field.
if (
ctx.requiresTrain &&
data.scheduledDate.trim() &&
!data.trainScheduleId.trim()
) {
refineCtx.addIssue({
code: "custom",
path: ["trainScheduleId"],
message: "Select a train for your shipment day.",
});
}
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
const schema = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
requiresTrain: true,
});
const values = (over: Record<string, unknown> = {}) => ({
...initialShipmentFormValues,
cargoWeightTons: "10",
paymentCurrency: "USD",
scheduledDate: "2026-08-10",
...over,
});
const trainIssue = (input: Record<string, unknown>) => {
const result = schema.safeParse(input);
return result.success
? undefined
: result.error.issues.find((i) => i.path[0] === "trainScheduleId");
};
describe("requiresTrain", () => {
it("rejects a dated export completion without a train pick", () => {
expect(trainIssue(values())?.message).toMatch(/select a train/i);
});
it("passes once a train is picked", () => {
expect(trainIssue(values({ trainScheduleId: "sched-1" }))).toBeUndefined();
});
it("stays silent while no date is chosen (day error covers it)", () => {
expect(trainIssue(values({ scheduledDate: "" }))).toBeUndefined();
});
it("is off by default (non-completion flows)", () => {
const plain = createShipmentFormSchema({
isContainer: false,
isHazardous: false,
isReefer: false,
});
const result = plain.safeParse(values());
expect(
result.success ||
result.error.issues.every((i) => i.path[0] !== "trainScheduleId"),
).toBe(true);
});
});

View File

@@ -610,10 +610,14 @@ export const api = {
),
validateShipment: endpoint<
{ id: string; dto: Freight.CreateBookingUnderContractDto },
{
id: string;
dto: Freight.CreateBookingUnderContractDto;
excludeBookingId?: string;
},
ShipmentValidation
>("contracts", "validateShipment", ({ id, dto }) =>
contractsService.validateShipment(id, dto),
>("contracts", "validateShipment", ({ id, dto, excludeBookingId }) =>
contractsService.validateShipment(id, dto, excludeBookingId),
),
getContractMilestones: endpoint<

View File

@@ -193,6 +193,34 @@ export interface BookingListFilter {
sortOrder?: "ASC" | "DESC";
}
export interface BookingWagonContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
export interface BookingWagonAllocation {
sequenceNo: number;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: "CONTAINER" | "BULK";
status: "PLANNED" | "RESERVED" | "LOADED" | "DEPARTED";
trainNumber: string | null;
departureAt: string | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: BookingWagonContainer[];
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
@@ -315,6 +343,16 @@ export const bookingsService = {
return data.data;
},
customerCancel: async (
id: string,
reason?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/customer-cancel`, {
reason,
});
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
@@ -548,6 +586,15 @@ export const bookingsService = {
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Allocated wagons for a paid booking (empty until placed on a train).
* One row per wagon with its containers / bulk load.
*/
getWagons: async (bookingId: string): Promise<BookingWagonAllocation[]> => {
const { data } = await client.get(`/api/bookings/${bookingId}/wagons`);
return (data.data ?? data) as BookingWagonAllocation[];
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).

View File

@@ -400,8 +400,13 @@ export const contractsService = {
validateShipment: async (
id: string,
dto: Freight.CreateBookingUnderContractDto,
// Completion/resubmit: exclude this booking's own containers from the
// same-train clash check.
excludeBookingId?: string,
): Promise<ShipmentValidation> => {
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto, {
params: excludeBookingId ? { bookingId: excludeBookingId } : undefined,
});
return data.data ?? data;
},

View File

@@ -48,35 +48,23 @@ export function defaultPaymentReason(
: "Freight invoice";
}
/**
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
* rather than our internal "bill" abstraction (plan §6.6).
*/
function subjectOf(referenceType: PaymentReferenceType): string {
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
}
export function reasonToDescription(
reason: string | null | undefined,
referenceType: PaymentReferenceType,
): string {
const subject = subjectOf(referenceType);
/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */
export function reasonToDescription(reason: string | null | undefined): string {
switch (reason) {
case "ALREADY_PAID":
return `This ${subject} has already been paid.`;
return "Already paid";
case "CANCELLED":
return `This ${subject} has been cancelled.`;
return "Cancelled";
case "REFUNDED":
return `This ${subject} has been refunded.`;
return "Refunded";
case "EXPIRED":
return `This ${subject} has expired and can no longer be paid.`;
return "Expired";
// A bill reference we issued whose order has since vanished from the domain app. Same
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
case "NOT_FOUND":
return "Bill not found.";
return "Bill not found";
default:
return `This ${subject} is no longer payable.`;
return "Not payable";
}
}
@@ -135,7 +123,7 @@ export class BillResolverService {
}`,
);
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
throw new CbeBillError("Service unavailable", "TRANSIENT");
}
}
}

View File

@@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto";
* CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are
* the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then
* presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200
* with Response_Code "3"; only authentication answers 401 (plan D6/D7).
* with Response_Code "2"; only authentication answers 401 (plan D6/D7).
*/
@ApiTags("CBE Unified Bill (inbound)")
@Controller("cbe")

View File

@@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason {
/**
* Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3).
* Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
* Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the
* controller); the exception filter only catches auth, validation, and the unexpected.
*/
@Injectable()
@@ -130,7 +130,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -184,11 +184,15 @@ export class CbeBillService {
);
if (prior) {
if (prior.tradeStatus === "SUCCESS") {
// Replay the stored body verbatim. Never re-settle.
return prior.responsePayload as unknown as CbePaymentResponseDto;
// Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored
// success — every repeat answers "Already paid". Money moved exactly once (the first
// call); this only changes what a duplicate hears back. NOTE this diverges from the
// original §6.5 replay design: if CBE retries because our SUCCESS response was lost
// in transit, it now sees FAILED for a debit we kept — reconcile such cases manually.
return mapPaymentFailure(dto, "Already paid");
}
if (prior.tradeStatus === "PENDING") {
return mapPaymentFailure(dto, "Payment is being processed.");
return mapPaymentFailure(dto, "Payment in progress");
}
if (prior.failureClass === "BUSINESS") {
// Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already
@@ -196,7 +200,7 @@ export class CbeBillService {
// echoing the original reason, which no longer describes this request.
return mapPaymentFailure(
dto,
`End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`,
`Already processed: ${prior.responseDescription ?? "failed"}`,
);
}
// FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement.
@@ -209,7 +213,7 @@ export class CbeBillService {
if (settled) {
return mapPaymentFailure(
dto,
`Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`,
"Duplicate transaction ref",
);
}
@@ -237,7 +241,7 @@ export class CbeBillService {
} catch (err) {
if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) {
// Concurrent duplicate of the same attempt lost the insert race.
return mapPaymentFailure(dto, "Payment is being processed.");
return mapPaymentFailure(dto, "Payment in progress");
}
throw err;
}
@@ -248,7 +252,7 @@ export class CbeBillService {
intent = await this.resolveIntent(dto.Bill_Id);
if (dto.Currency && dto.Currency !== intent.currency) {
throw new CbeBillError("Payment currency does not match.", "BUSINESS");
throw new CbeBillError("Currency mismatch", "BUSINESS");
}
// Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous
@@ -256,7 +260,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) {
throw new CbeBillError(
reasonToDescription(billQuery.reason, intent.referenceType),
reasonToDescription(billQuery.reason),
"BUSINESS",
);
}
@@ -267,7 +271,7 @@ export class CbeBillService {
Math.abs(amount - intent.amountMinor) >
intent.amountMinor * AMOUNT_TOLERANCE
) {
throw new CbeBillError("Payment amount does not match.", "BUSINESS");
throw new CbeBillError("Amount mismatch", "BUSINESS");
}
const paidAt = new Date(dto.Timestamp);
@@ -325,10 +329,10 @@ export class CbeBillService {
private assertIntentPayable(intent: PaymentIntent): void {
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
if (intent.status === ProviderPaymentStatus.PROCESSING) {
throw new CbeBillError("Payment is being processed.", "BUSINESS");
throw new CbeBillError("Payment in progress", "BUSINESS");
}
throw new CbeBillError(
reasonToDescription(localReason(intent), intent.referenceType),
reasonToDescription(localReason(intent)),
"BUSINESS",
);
}
@@ -336,11 +340,11 @@ export class CbeBillService {
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> {
if (!this.billReferenceService.isValid(billId)) {
throw new CbeBillError("Bill not found.", "BUSINESS");
throw new CbeBillError("Bill not found", "BUSINESS");
}
const intent = await this.intentsRepository.findByBillReference(billId);
if (!intent || intent.provider !== ProviderMethod.CBE_BILL) {
throw new CbeBillError("Bill not found.", "BUSINESS");
throw new CbeBillError("Bill not found", "BUSINESS");
}
return intent;
}

View File

@@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
Status: "FAILED",
Response_Code: "9",
Response_Description: "Service temporarily unavailable.",
Response_Description: "Service unavailable",
});
return;
}
@@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter {
: exception.message;
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: message || "Invalid request",
});
return;
@@ -65,8 +65,8 @@ export class CbeExceptionFilter implements ExceptionFilter {
);
response.status(HttpStatus.OK).json({
Status: "FAILED",
Response_Code: "3",
Response_Description: "Internal server error.",
Response_Code: "2",
Response_Description: "Internal error",
});
}
}

View File

@@ -16,8 +16,8 @@ export class CbeBillError extends Error {
}
/**
* Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only
* 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6).
* Every failure maps to Response_Code "2" (per current CBE integration requirement; the
* original AAFDA plan used 3); only the description is specific (plan §6.6).
*/
export function toCbeFailure(err: unknown): {
description: string;
@@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): {
if (err instanceof CbeBillError) {
return { description: err.message, failureClass: err.failureClass };
}
return { description: "Internal server error.", failureClass: "TRANSIENT" };
return { description: "Internal error", failureClass: "TRANSIENT" };
}

View File

@@ -27,7 +27,7 @@ export function mapPaymentFailure(
Cbe_Txn_Ref: request.Cbe_Txn_Ref,
Destination_Txn_Ref: "",
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};

View File

@@ -48,7 +48,7 @@ export function mapQueryFailure(
Transaction_Type: "",
Timestamp: new Date().toISOString(),
Status: "FAILED",
Response_Code: "3",
Response_Code: "2",
Response_Description: description,
Additional_Fields: [],
};

View File

@@ -122,15 +122,17 @@ describe("CBE Unified Bill (payment service as biller)", () => {
End_To_End_Txn_Id: txnId("q404"),
Bill_Id: "000000000000",
});
// Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200
// Business failures are HTTP 200 + Response_Code "2" — CBE treats a non-200
// as a channel fault and retries.
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
expect(res.body.Response_Code).toBe("2");
});
let settleBody: Record<string, string>;
it("settles the freight invoice when CBE reports the debit", async () => {
const invoice = await currentInvoice(invoiceId);
const res = await cbe(token, "/cbe/payment", {
settleBody = {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("p1"),
Cbe_Txn_Ref: `CBE${Date.now()}`,
@@ -140,7 +142,8 @@ describe("CBE Unified Bill (payment service as biller)", () => {
Currency: "ETB",
Full_Name: "IT Payer",
Phone_No: "+251911000001",
});
};
const res = await cbe(token, "/cbe/payment", settleBody);
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0");
@@ -154,6 +157,24 @@ describe("CBE Unified Bill (payment service as biller)", () => {
expect(paid.status).toBe("PAID");
});
it("answers 'Already paid' when the settled attempt is sent again verbatim", async () => {
const res = await cbe(token, "/cbe/payment", settleBody);
expect(res.status).toBe(200);
expect(res.body.Status).toBe("FAILED");
expect(res.body.Response_Code).toBe("2");
expect(res.body.Response_Description).toBe("Already paid");
});
it("answers 'Already paid' when the settled End_To_End_Txn_Id is reused with a different amount", async () => {
const res = await cbe(token, "/cbe/payment", {
...settleBody,
Amount: "1.00",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("2");
expect(res.body.Response_Description).toBe("Already paid");
});
it("rejects a second debit on the same bill", async () => {
const res = await cbe(token, "/cbe/payment", {
Destination_Api_Name: API_NAME,
@@ -165,7 +186,7 @@ describe("CBE Unified Bill (payment service as biller)", () => {
Currency: "ETB",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
expect(res.body.Response_Code).toBe("2");
});
it("reports an already-paid bill on a later query", async () => {
@@ -175,6 +196,6 @@ describe("CBE Unified Bill (payment service as biller)", () => {
Bill_Id: billId,
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
expect(res.body.Response_Code).toBe("2");
});
});