mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #608 from Tria-plc/freight_feature/usermanagement
general contrat
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // invoiceService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user