mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1145 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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're about to cancel booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
. Since you haven'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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 & 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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user