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

general contrat
This commit is contained in:
marshal
2026-07-10 21:00:01 +03:00
committed by GitHub
23 changed files with 639 additions and 89 deletions

View File

@@ -18,7 +18,7 @@ jobs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v4e
with:
fetch-depth: 2

View File

@@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,

View File

@@ -804,9 +804,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
if (options.bookingType) {
qb.andWhere('booking.bookingType = :bookingType', {
bookingType: options.bookingType,
});
// The stored booking_type column is 'ONE_TIME' for every row (contract
// drawdowns included — see contract-booking.service create), so the
// one-time vs general split keys on the denormalized contract_kind:
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
// = everything else (ONE_TIME contracts and legacy contract-less rows).
if (options.bookingType === 'GENERAL_CONTRACT') {
qb.andWhere("booking.contract_kind = 'GENERAL'");
} else {
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
}
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {

View File

@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // invoiceService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingTransitionService
);
return { service, contractsRepository };
}

View File

@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingTransitionService
);
return {
service,

View File

@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
@@ -72,6 +73,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {}
async createUnderContract(
@@ -331,6 +334,227 @@ export class ContractBookingService {
return { booking: result ?? booking, warnings };
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
*/
async initiateUnderContract(
contractId: string,
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
);
}
if (contract.status === 'CONTRACT_CLOSED') {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
// until the customer completes it after clearance.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings: [] };
}
/**
* Complete a bare initiated booking after Operations finalized its per-booking
* clearance (CLEARANCE_READY) or returned it for changes
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
* window + open-departure checks, pricing, consolidation and invoicing all run
* here — the same gates a one-time shipment passes at creation.
*/
async completeUnderContract(
contractId: string,
bookingId: string,
dto: CreateBookingUnderContractDto,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.',
);
}
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const freightType = contract.freightType;
const hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A zero price means no contract rate matches — roll the cargo back so
// the instance stays CLEARANCE_READY and can be completed again once
// the contract rates are fixed (the clearance work is not lost).
if (!(computed.totalAmount > 0)) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
// Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
// exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the
// operations queue.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
) {
await this.bookingsRepository.update(booking.id, {
scheduledDate: new Date(dto.scheduledDate),
} as never);
const parked = await this.consolidateDrawdown(
withContainers,
'OPERATION_REQUEST_PENDING',
);
warnings.push(parked.message);
if (!parked.paired) {
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: pendingResult ?? booking, warnings };
}
}
// Invoice the now-priced booking (idempotent, non-blocking).
await this.finalizeContractBooking(booking.id, contract, false);
await this.maybeCompleteContract(contract);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
// and the staff notification — the exact machine a one-time booking uses.
const completed = await this.bookingTransitionService.requestOperation(
booking.id,
dto.scheduledDate,
);
return { booking: completed, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.

View File

@@ -799,6 +799,37 @@ export class ContractsController {
);
}
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.contractBookingService.initiateUnderContract(
id,
{ contractRouteId: dto?.contractRouteId },
{ id: user?.id ?? user?.sub },
user,
);
}
@Post(':id/bookings/:bookingId/complete')
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
})
completeBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
) {
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
}
@Post(':id/validate-shipment')
@ApiOperation({
summary:

View File

@@ -197,12 +197,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
// {
// label: "Self-Clearance Review",
// href: "/dashboard/contracts/ops-clearance",
// icon: <ShieldCheck />,
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
// },
// Operations Path A queue: per-booking self-clearance review for
// GENERAL non-customs booking instances (and legacy self-clear bookings).
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",

View File

@@ -380,6 +380,31 @@ export function ClearanceReviewSection({
</Text>
</Group>
</Paper>
) : clearance.status !== "DOCUMENTS_UNDER_REVIEW" ? (
// Finalize is only valid from DOCUMENTS_UNDER_REVIEW (the API rejects
// any other status with a 409) — once the booking moved on, show the
// finalized state instead of a button that can only fail.
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
{clearance.allApproved ? (
<CheckCircle2 size={15} />
) : (
<FileCheck2 size={15} />
)}
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "Clearance has been finalized. The customer can now pick a shipment day and proceed to operation."
: "Finalization unlocks once the customer submits their documents and every required document is approved."}
</Text>
</Group>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">

View File

@@ -308,6 +308,12 @@ const App = () => {
path="/contracts/:id/bookings/new"
element={<NewShipmentPage />}
/>
{/* Completion of an initiated (bare) booking after per-booking
clearance — same form, submits to the complete endpoint. */}
<Route
path="/contracts/:id/bookings/:bookingId/complete"
element={<NewShipmentPage />}
/>
<Route
path="/contracts/:id/clearance"
element={<ContractClearanceFlow />}

View File

@@ -1,10 +1,14 @@
import { Button, Group, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction";
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
@@ -56,6 +60,18 @@ export function ContractCustomerAction({
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
}
if (action.type === "initiate") {
return (
<InitiateBookingButton
contract={action.contract}
label={action.label}
icon={action.icon}
size={size}
listStyle={listStyle}
/>
);
}
const Icon = action.icon;
const variant = action.primary ? "filled" : "light";
@@ -80,6 +96,88 @@ export function ContractCustomerAction({
);
}
/**
* One-click bare booking instance under a GENERAL non-customs contract. No
* form, no date, no window gate — the new instance lands in per-booking
* clearance (AWAITING_DOCUMENTS) and the customer is taken straight to it.
*/
export function InitiateBookingButton({
contract,
label = "Initiate booking",
icon: Icon,
size = "xs",
listStyle = false,
fullWidth = false,
}: {
contract: Freight.IContract;
label?: string;
icon: LucideIcon;
size?: ButtonProps["size"];
listStyle?: boolean;
fullWidth?: boolean;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () =>
api.contracts.initiateBookingUnderContract.call({
id: contract.id,
// Multi-route contracts must name a route; single-route auto-selects.
contractRouteId:
(contract.routes?.length ?? 0) > 1
? contract.routes![0].id
: undefined,
}),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contract.id }),
});
toast.success(
"Booking initiated — upload your clearance documents to start the review.",
);
navigate(`/bookings/${booking.id}`);
},
onError: (e: Error) =>
toast.error(e.message || "Could not initiate the booking"),
});
return (
<Button
size={size}
radius="md"
h={listStyle ? 34 : undefined}
variant="filled"
color="edr-green"
fullWidth={fullWidth}
leftSection={<Icon size={15} />}
loading={mutation.isPending}
onClick={(e) => {
e.stopPropagation();
mutation.mutate();
}}
styles={
listStyle
? {
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap" as const,
boxShadow: "0 1px 2px rgba(14,163,113,0.25)",
},
}
: undefined
}
fw={listStyle ? undefined : 700}
fz={listStyle ? undefined : 13}
>
{label}
</Button>
);
}
/** Action column cell: doc button + primary customer action. */
export function ContractCustomerActionCell({
contract,

View File

@@ -72,6 +72,14 @@ export type ContractCustomerAction =
label: string;
primary: boolean;
icon: LucideIcon;
}
| {
/** One-click bare booking instance (GENERAL non-customs) — mutation, not navigation. */
type: "initiate";
contract: Freight.IContract;
label: string;
primary: boolean;
icon: LucideIcon;
};
function findPayableBookingForContract(
@@ -210,6 +218,15 @@ export function deriveContractCustomerAction(
}
const bookingAction = getContractBookingAction(contract, bookings);
if (bookingAction.kind === "initiate") {
return {
type: "initiate",
contract,
label: "Initiate booking",
primary: true,
icon: PackagePlus,
};
}
if (bookingAction.kind === "book") {
return {
type: "navigate",

View File

@@ -142,6 +142,9 @@ export const URL_CONSTANTS = {
`/api/contracts/${id}/clearance/documents`,
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
BOOKINGS_INITIATE: (id: string) => `/api/contracts/${id}/bookings/initiate`,
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/api/contracts/${id}/bookings/${bookingId}/complete`,
VALIDATE_SHIPMENT: (id: string) =>
`/api/contracts/${id}/validate-shipment`,
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,

View File

@@ -1,29 +1,28 @@
import { Alert, Button, Group } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CardTitle, SectionCard } from "./layout";
/**
* Customer-facing clearance section on the booking detail page: shows the
* resolved document grid, lets the customer (re)upload pending/queried documents
* plus ad-hoc named documents, and proceed to operation once Global Logistics
* marks the booking CLEARANCE_READY.
*
* The flow body, calendar, and mutations are shared with the home-page action
* modal via `useClearanceFlow` / `ClearanceFlow`.
* Customer-facing clearance section on the booking detail page: a compact
* status summary with a single action button. The document grid, re-uploads,
* and the shipment-day picker all live in the shared {@link BookingActionModal}
* (the same modal the My Shipments list uses), so the flow behaves identically
* from both entry points.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
const flow = useClearanceFlow(booking);
const [modalOpen, setModalOpen] = useState(false);
const status = booking.status as string;
const action = getBookingNextAction(booking);
if (flow.status === "OPERATION_REQUESTED") {
if (status === "OPERATION_REQUESTED") {
return (
<SectionCard>
<CardTitle>Operation</CardTitle>
@@ -34,56 +33,51 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
);
}
if (flow.isLoading || !flow.clearance) {
return (
<SectionCard>
<CardTitle>Clearance documents</CardTitle>
</SectionCard>
const summary =
status === "CLEARANCE_READY" ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
Clearance is complete. Pick a shipment day and proceed to operation.
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
Your documents are being reviewed. Re-upload any queried documents to
proceed approved documents stay as they are.
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
Upload the required clearance documents so your shipment can be
reviewed.
</Alert>
);
}
return (
<SectionCard>
<BookingClearanceWorkflowBanner booking={booking} />
<Group justify="space-between" align="center" mb="md" mt="md">
<CardTitle>Clearance documents</CardTitle>
{action && (
<Button
color="edr-green"
radius="md"
leftSection={<ClipboardList size={16} />}
onClick={() => setModalOpen(true)}
>
{action.label}
</Button>
)}
</Group>
<ClearanceFlow
{summary}
<Text fz="12.5px" c="dimmed" mt="sm">
Use {action?.label ?? "the action button"} to manage your clearance
documents.
</Text>
<BookingActionModal
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="lg" gap="sm">
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
flow.proceedToOperation({
onSuccess: () => navigate(`/bookings/${booking.id}`),
})
}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
}
opened={modalOpen}
onClose={() => setModalOpen(false)}
/>
</SectionCard>
);

View File

@@ -16,14 +16,23 @@ export const PROGRESS_STAGES = [
statuses: ["DRAFT", "CHANGES_REQUESTED"],
},
{
// AWAITING_DOCUMENTS: a fresh contract-drawdown booking lands here — the
// customer just submitted it and now uploads clearance documents.
label: "Submitted",
icon: ClipboardCheck,
statuses: ["SUBMITTED"],
statuses: ["SUBMITTED", "AWAITING_DOCUMENTS"],
},
{
// Clearance review is the approval step for contract-drawdown bookings.
label: "Approval",
icon: ShieldCheck,
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
statuses: [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY",
],
},
{
label: "Contract",
@@ -37,6 +46,7 @@ export const PROGRESS_STAGES = [
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"OPERATION_REQUESTED",
],
},
{
@@ -234,24 +244,24 @@ export const STATUS_MAP: Record<
title: "Clearance documents needed",
description:
"Upload the required clearance documents so your shipment can be reviewed.",
stage: 5,
stage: 1,
},
DOCUMENTS_UNDER_REVIEW: {
title: "Documents under review",
description:
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
stage: 5,
stage: 2,
},
CLEARANCE_READY: {
title: "Cleared — choose a shipment day",
description:
"Clearance is complete. Pick a shipment day and proceed to operation.",
stage: 5,
stage: 2,
},
OPERATION_REQUESTED: {
title: "Operation requested",
description: "Operation requested. An operator will take your shipment forward.",
stage: 5,
stage: 4,
},
CONTRACT_ACTIVE: {
title: "Contract active",

View File

@@ -1,5 +1,6 @@
import { Box, Button, Group, Modal, Text } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import { CheckCircle2, PackagePlus, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -40,6 +41,7 @@ function BookingActionModalBody({
}) {
const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking);
const navigate = useNavigate();
const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
@@ -91,17 +93,31 @@ function BookingActionModalBody({
Submit documents
</Button>
)}
{flow.isReady && (
{flow.needsCompletion && flow.completeTo ? (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
leftSection={<PackagePlus size={16} />}
onClick={() => {
onClose();
navigate(flow.completeTo!);
}}
>
Proceed to operation
Complete booking
</Button>
) : (
flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)
)}
</Group>
}

View File

@@ -54,6 +54,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
customerDocs,
glDocs,
isReady,
needsCompletion,
canUpload,
isInitialUpload,
status,
@@ -78,9 +79,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
<Stack gap={0}>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
{needsCompletion
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
: clearance.includesCustoms
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
: "Clearance is ready. You can now proceed to operation."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
@@ -208,7 +211,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</Alert>
)}
{isReady && (
{isReady && !needsCompletion && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day

View File

@@ -67,6 +67,16 @@ export function useClearanceFlow(booking: Freight.IBooking) {
);
const isReady = status === "CLEARANCE_READY";
// Bare initiated instance (GENERAL non-customs "Initiate booking"): created
// with no cargo and no price. Once ready it is COMPLETED on the full booking
// form (cargo + shipment day + window check), not date-only proceed.
const needsCompletion =
isReady &&
Boolean(booking.contractId) &&
!(Number(booking.totalAmount ?? 0) > 0);
const completeTo = needsCompletion
? `/contracts/${booking.contractId}/bookings/${booking.id}/complete`
: null;
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
// The very first upload (nothing in review yet). Here every required document
@@ -146,6 +156,8 @@ export function useClearanceFlow(booking: Freight.IBooking) {
customerDocs,
glDocs,
isReady,
needsCompletion,
completeTo,
canUpload,
isInitialUpload,
// staged upload state

View File

@@ -61,6 +61,7 @@ import { labelForDocCode } from "@/pages/bookings/resubmit";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -348,6 +349,9 @@ export default function ContractDetailPage() {
const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request";
// GENERAL non-customs import/export: one-click bare booking instance — the
// per-booking clearance runs first, so no window gate applies here.
const canInitiateBooking = bookingAction.kind === "initiate";
// Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action.
const glPreparingBooking = customsPath && clearanceFinalized;
@@ -438,6 +442,13 @@ export default function ContractDetailPage() {
Request shipment
</Button>
)}
{canInitiateBooking && (
<InitiateBookingButton
contract={contract}
icon={PackagePlus}
size="md"
/>
)}
{canBookShipment &&
(bookingWindowOpen ? (
<Button
@@ -1289,6 +1300,13 @@ export default function ContractDetailPage() {
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel>
{canInitiateBooking && (
<InitiateBookingButton
contract={contract}
icon={PackagePlus}
size="xs"
/>
)}
{canBookShipment && bookingWindowOpen && (
<Button
color="edr-green"

View File

@@ -77,7 +77,12 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
};
export default function NewShipmentPage() {
const { id } = useParams<{ id: string }>();
// With `bookingId` the page runs in COMPLETION mode: the bare booking
// instance (created by "Initiate booking") already passed per-booking
// clearance, and this form supplies the deferred cargo + shipment day. Same
// window gates, same validation, same price confirmation — the submit just
// completes the existing booking instead of creating a new one.
const { id, bookingId } = useParams<{ id: string; bookingId?: string }>();
const navigate = useNavigate();
const { data: contract, isLoading } = useQuery(
@@ -204,7 +209,13 @@ export default function NewShipmentPage() {
);
}
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
return (
<NewShipmentBookingForm
contract={contract}
contractId={id!}
completeBookingId={bookingId}
/>
);
}
function bulkUnitOfMeasure(
@@ -219,9 +230,12 @@ function bulkUnitOfMeasure(
function NewShipmentBookingForm({
contract,
contractId,
completeBookingId,
}: {
contract: Freight.IContract;
contractId: string;
/** Set when completing an initiated (bare) booking after clearance. */
completeBookingId?: string;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -251,7 +265,13 @@ function NewShipmentBookingForm({
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
completeBookingId
? api.contracts.completeBookingUnderContract.call({
id: contractId,
bookingId: completeBookingId,
dto,
})
: api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
queryClient.invalidateQueries({
@@ -368,10 +388,12 @@ function NewShipmentBookingForm({
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
New Shipment Booking
{completeBookingId ? "Complete Your Booking" : "New Shipment Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Book a shipment against contract {contract.reference}.
{completeBookingId
? `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>
<Button
@@ -398,7 +420,9 @@ function NewShipmentBookingForm({
mb="lg"
>
<Text size="sm" fw={600}>
Failed to create the shipment booking
{completeBookingId
? "Failed to complete the booking"
: "Failed to create the shipment booking"}
</Text>
<Text size="sm" mt={4} c="red.7">
{submitMutation.error instanceof Error

View File

@@ -15,7 +15,12 @@ export const TERMINAL_BOOKING_STATUSES = [
/** Path A statuses where a customer (no customs) may book against the contract. */
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
export type ContractBookingActionKind = "book" | "rebook" | "request" | "none";
export type ContractBookingActionKind =
| "book"
| "rebook"
| "request"
| "initiate"
| "none";
export interface ContractBookingAction {
kind: ContractBookingActionKind;
@@ -63,5 +68,13 @@ export function getContractBookingAction(
return { kind: hasExpired ? "rebook" : "book", to };
}
// GENERAL non-customs import/export: one-click bare booking instance — the
// per-booking clearance runs first, cargo + shipment day come at completion.
// No window gate here; the window is checked when the booking is completed.
// DOMESTIC (intercity) keeps the direct booking form.
if (contract.tradeDirection !== "DOMESTIC") {
return { kind: "initiate", to: `/contracts/${contract.id}` };
}
return { kind: "book", to };
}

View File

@@ -531,6 +531,24 @@ export const api = {
contractsService.createBookingUnderContract(id, dto),
),
initiateBookingUnderContract: endpoint<
{ id: string; contractRouteId?: string },
Freight.IBooking
>("contracts", "initiateBookingUnderContract", ({ id, contractRouteId }) =>
contractsService.initiateBookingUnderContract(id, contractRouteId),
),
completeBookingUnderContract: endpoint<
{
id: string;
bookingId: string;
dto: Freight.CreateBookingUnderContractDto;
},
Freight.IBooking
>("contracts", "completeBookingUnderContract", ({ id, bookingId, dto }) =>
contractsService.completeBookingUnderContract(id, bookingId, dto),
),
validateShipment: endpoint<
{ id: string; dto: Freight.CreateBookingUnderContractDto },
ShipmentValidation

View File

@@ -329,6 +329,32 @@ export const contractsService = {
return data.data.booking ?? data.data;
},
/**
* One-click bare booking instance under a GENERAL non-customs contract — no
* cargo, no date. The instance enters per-booking clearance; the customer
* completes it (cargo + shipment day) once Operations finalizes.
*/
initiateBookingUnderContract: async (
id: string,
contractRouteId?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
C.BOOKINGS_INITIATE(id),
contractRouteId ? { contractRouteId } : {},
);
return data.data.booking ?? data.data;
},
/** Complete an initiated booking after clearance — same DTO as create. */
completeBookingUnderContract: async (
id: string,
bookingId: string,
dto: Freight.CreateBookingUnderContractDto,
): Promise<Freight.IBooking> => {
const { data } = await client.post(C.BOOKINGS_COMPLETE(id, bookingId), dto);
return data.data.booking ?? data.data;
},
/**
* Pre-submit validation of a shipment booking (same DTO as
* `createBookingUnderContract`). Returns overweight warnings and hard-block