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

add contract clearance detail page and enhance contract requests fil…
This commit is contained in:
marshal
2026-07-19 12:48:00 +03:00
committed by GitHub
5 changed files with 82 additions and 6 deletions

View File

@@ -596,11 +596,40 @@ export class ContractBookingService {
if (!booking || booking.contractId !== contract.id) { if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`); throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
} }
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { if (
!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
booking.status,
)
) {
throw new BadRequestException( throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.', 'Clearance must be finalized before the booking can be completed.',
); );
} }
// An unpaid booking that expired at train dispatch keeps its finished
// per-booking clearance — GL rebooks it onto a new shipment day instead of
// forcing the customer through a new shipment request + clearance fee.
if (booking.status === 'EXPIRED') {
// Only a booking that completed once (it has a price, so its clearance
// finished and cargo is persisted) can be rebooked after expiry.
if (!(Number(booking.totalAmount) > 0)) {
throw new BadRequestException(
'Only a previously completed booking can be rebooked after it expires.',
);
}
// Expiry released the booking's contract-capacity hold; if the payload
// re-states the cargo, make sure the released share is still free.
if (dto.containers?.length || dto.bulkLines?.length) {
await this.assertWithinQuantityCap(contract, dto);
}
// Drop the departed train's link and fall into the day-only resubmit
// path below — same machinery as OPERATION_CHANGES_REQUESTED.
await this.bookingsRepository.update(booking.id, {
status: 'OPERATION_CHANGES_REQUESTED',
trainScheduleId: null,
} as never);
booking.status = 'OPERATION_CHANGES_REQUESTED';
booking.trainScheduleId = null;
}
// Path B: only GL Ethiopia completes a customs instance — the customer // Path B: only GL Ethiopia completes a customs instance — the customer
// never enters shipment data on a customs contract. // never enters shipment data on a customs contract.
if (contract.customsClearingEnabled) { if (contract.customsClearingEnabled) {

View File

@@ -407,6 +407,9 @@ export default function GlCreateBookingForm() {
useEffect(() => { useEffect(() => {
if (!bookingRequest || prefilled) return; if (!bookingRequest || prefilled) return;
// A rebook (?copyFrom=) seeds from the expired booking's real cargo —
// richer than the request's bare quantities. Let that seed win the race.
if (copyFromParam) return;
setPrefilled(true); setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {}; const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) { if (lines.containers?.length) {
@@ -448,13 +451,27 @@ export default function GlCreateBookingForm() {
setContainerLines( setContainerLines(
lines.map((c) => { lines.map((c) => {
const qty = Math.max(1, c.quantity); const qty = Math.max(1, c.quantity);
// Carry the persisted per-unit details (numbers, seals, VGM, handling)
// when the source booking has them — a rebooked EXPIRED booking does,
// and its cargo is fixed server-side anyway.
const units: UnitDraft[] =
c.units?.length === qty
? c.units.map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: u.vgmTons != null ? String(u.vgmTons) : "",
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
}))
: Array.from({ length: qty }, emptyUnit);
return { return {
containerSize: String(c.containerType?.sizeFt ?? ""), containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty), quantity: String(qty),
hazardousQuantity: "0", hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: "0", reeferQuantity: String(units.filter((u) => u.isReefer).length),
returnQuantity: "0", returnQuantity: String(units.filter((u) => u.isReturn).length),
units: Array.from({ length: qty }, emptyUnit), units,
}; };
}), }),
); );

View File

@@ -23,6 +23,7 @@ import {
Clock, Clock,
PackageCheck, PackageCheck,
PackagePlus, PackagePlus,
RotateCcw,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() {
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user); !isDjiboutiGl(user);
// The completed booking expired unpaid at train dispatch. Its per-booking
// clearance is finished, so GL rebooks it onto a new day — the customer never
// re-requests the shipment or pays the clearance fee again.
const canRebookExpired =
booking?.status === "EXPIRED" &&
Boolean(booking?.contractId) &&
Number(booking?.totalAmount ?? 0) > 0 &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const docsPhaseComplete = const docsPhaseComplete =
clearance?.milestones?.some( clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() {
> >
Create booking Create booking
</Button> </Button>
) : canRebookExpired ? (
<Button
color="edr-green"
radius="md"
leftSection={<RotateCcw size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete?copyFrom=${id}`,
)
}
>
Rebook shipment
</Button>
) : undefined ) : undefined
} }
/> />

View File

@@ -727,8 +727,12 @@ export default function ContractClearanceListPage() {
) )
} }
onRebook={(row) => onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh create-booking would spawn a
// new instance and force the customer through clearance + fee
// again.
navigate( navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`, `/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
) )
} }
onViewContract={(contractId) => onViewContract={(contractId) =>

View File

@@ -79,6 +79,8 @@ export interface BookingContainerUnit {
vgmTons: number; vgmTons: number;
isHazardous?: boolean; isHazardous?: boolean;
isReefer?: boolean; isReefer?: boolean;
/** This container ships back empty after unloading (equipment return). */
isReturn?: boolean;
sortOrder?: number; sortOrder?: number;
} }