mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
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 }}
|
matrix: ${{ steps.filter.outputs.matrix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4e
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingLifecycleNotifierService,
|
BookingLifecycleNotifierService,
|
||||||
|
BookingTransitionService,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
|
|||||||
@@ -804,9 +804,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.bookingType) {
|
if (options.bookingType) {
|
||||||
qb.andWhere('booking.bookingType = :bookingType', {
|
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||||
bookingType: options.bookingType,
|
// 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) {
|
if (options.createdFrom) {
|
||||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // bookingTransitionService
|
||||||
);
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // bookingTransitionService
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
service,
|
service,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
|||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
@@ -72,6 +73,8 @@ export class ContractBookingService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
@Inject(forwardRef(() => BookingTransitionService))
|
||||||
|
private readonly bookingTransitionService: BookingTransitionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -331,6 +334,227 @@ export class ContractBookingService {
|
|||||||
return { booking: result ?? booking, warnings };
|
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
|
* 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.
|
* 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')
|
@Post(':id/validate-shipment')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
|
|||||||
@@ -196,12 +196,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
},
|
},
|
||||||
// {
|
// Operations Path A queue: per-booking self-clearance review for
|
||||||
// label: "Self-Clearance Review",
|
// GENERAL non-customs booking instances (and legacy self-clear bookings).
|
||||||
// href: "/dashboard/contracts/ops-clearance",
|
{
|
||||||
// icon: <ShieldCheck />,
|
label: "Self-Clearance Review",
|
||||||
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
href: "/dashboard/contracts/ops-clearance",
|
||||||
// },
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
|
|||||||
@@ -380,6 +380,31 @@ export function ClearanceReviewSection({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</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">
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
|||||||
@@ -308,6 +308,12 @@ const App = () => {
|
|||||||
path="/contracts/:id/bookings/new"
|
path="/contracts/:id/bookings/new"
|
||||||
element={<NewShipmentPage />}
|
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
|
<Route
|
||||||
path="/contracts/:id/clearance"
|
path="/contracts/:id/clearance"
|
||||||
element={<ContractClearanceFlow />}
|
element={<ContractClearanceFlow />}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Button, Group, type ButtonProps } from "@mantine/core";
|
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 type { ReactNode } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { ContractClearanceAction } from "./ContractClearanceAction";
|
import { ContractClearanceAction } from "./ContractClearanceAction";
|
||||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
|
|
||||||
@@ -56,6 +60,18 @@ export function ContractCustomerAction({
|
|||||||
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
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 Icon = action.icon;
|
||||||
const variant = action.primary ? "filled" : "light";
|
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. */
|
/** Action column cell: doc button + primary customer action. */
|
||||||
export function ContractCustomerActionCell({
|
export function ContractCustomerActionCell({
|
||||||
contract,
|
contract,
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ export type ContractCustomerAction =
|
|||||||
label: string;
|
label: string;
|
||||||
primary: boolean;
|
primary: boolean;
|
||||||
icon: LucideIcon;
|
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(
|
function findPayableBookingForContract(
|
||||||
@@ -210,6 +218,15 @@ export function deriveContractCustomerAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookingAction = getContractBookingAction(contract, bookings);
|
const bookingAction = getContractBookingAction(contract, bookings);
|
||||||
|
if (bookingAction.kind === "initiate") {
|
||||||
|
return {
|
||||||
|
type: "initiate",
|
||||||
|
contract,
|
||||||
|
label: "Initiate booking",
|
||||||
|
primary: true,
|
||||||
|
icon: PackagePlus,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (bookingAction.kind === "book") {
|
if (bookingAction.kind === "book") {
|
||||||
return {
|
return {
|
||||||
type: "navigate",
|
type: "navigate",
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
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) =>
|
VALIDATE_SHIPMENT: (id: string) =>
|
||||||
`/api/contracts/${id}/validate-shipment`,
|
`/api/contracts/${id}/validate-shipment`,
|
||||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
import { Alert, Button, Group } from "@mantine/core";
|
import { useState } from "react";
|
||||||
import { CheckCircle2, Upload } from "lucide-react";
|
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
|
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-facing clearance section on the booking detail page: shows the
|
* Customer-facing clearance section on the booking detail page: a compact
|
||||||
* resolved document grid, lets the customer (re)upload pending/queried documents
|
* status summary with a single action button. The document grid, re-uploads,
|
||||||
* plus ad-hoc named documents, and proceed to operation once Global Logistics
|
* and the shipment-day picker all live in the shared {@link BookingActionModal}
|
||||||
* marks the booking CLEARANCE_READY.
|
* (the same modal the My Shipments list uses), so the flow behaves identically
|
||||||
*
|
* from both entry points.
|
||||||
* The flow body, calendar, and mutations are shared with the home-page action
|
|
||||||
* modal via `useClearanceFlow` / `ClearanceFlow`.
|
|
||||||
*/
|
*/
|
||||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const navigate = useNavigate();
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const flow = useClearanceFlow(booking);
|
const status = booking.status as string;
|
||||||
|
const action = getBookingNextAction(booking);
|
||||||
|
|
||||||
if (flow.status === "OPERATION_REQUESTED") {
|
if (status === "OPERATION_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Operation</CardTitle>
|
<CardTitle>Operation</CardTitle>
|
||||||
@@ -34,56 +33,51 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow.isLoading || !flow.clearance) {
|
const summary =
|
||||||
return (
|
status === "CLEARANCE_READY" ? (
|
||||||
<SectionCard>
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
Clearance is complete. Pick a shipment day and proceed to operation.
|
||||||
</SectionCard>
|
</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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<BookingClearanceWorkflowBanner booking={booking} />
|
<BookingClearanceWorkflowBanner booking={booking} />
|
||||||
<Group justify="space-between" align="center" mb="md" mt="md">
|
<Group justify="space-between" align="center" mb="md" mt="md">
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
|
{action && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ClipboardList size={16} />}
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</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}
|
booking={booking}
|
||||||
flow={flow}
|
opened={modalOpen}
|
||||||
footer={
|
onClose={() => setModalOpen(false)}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,14 +16,23 @@ export const PROGRESS_STAGES = [
|
|||||||
statuses: ["DRAFT", "CHANGES_REQUESTED"],
|
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",
|
label: "Submitted",
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
statuses: ["SUBMITTED"],
|
statuses: ["SUBMITTED", "AWAITING_DOCUMENTS"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Clearance review is the approval step for contract-drawdown bookings.
|
||||||
label: "Approval",
|
label: "Approval",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
|
statuses: [
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
|
"APPROVED",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contract",
|
label: "Contract",
|
||||||
@@ -37,6 +46,7 @@ export const PROGRESS_STAGES = [
|
|||||||
"FULLY_EXECUTED",
|
"FULLY_EXECUTED",
|
||||||
"SELECTED_FOR_BATCH",
|
"SELECTED_FOR_BATCH",
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
"OPERATION_REQUESTED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -234,24 +244,24 @@ export const STATUS_MAP: Record<
|
|||||||
title: "Clearance documents needed",
|
title: "Clearance documents needed",
|
||||||
description:
|
description:
|
||||||
"Upload the required clearance documents so your shipment can be reviewed.",
|
"Upload the required clearance documents so your shipment can be reviewed.",
|
||||||
stage: 5,
|
stage: 1,
|
||||||
},
|
},
|
||||||
DOCUMENTS_UNDER_REVIEW: {
|
DOCUMENTS_UNDER_REVIEW: {
|
||||||
title: "Documents under review",
|
title: "Documents under review",
|
||||||
description:
|
description:
|
||||||
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
"Your clearance documents are being reviewed. Re-upload any queried documents to proceed.",
|
||||||
stage: 5,
|
stage: 2,
|
||||||
},
|
},
|
||||||
CLEARANCE_READY: {
|
CLEARANCE_READY: {
|
||||||
title: "Cleared — choose a shipment day",
|
title: "Cleared — choose a shipment day",
|
||||||
description:
|
description:
|
||||||
"Clearance is complete. Pick a shipment day and proceed to operation.",
|
"Clearance is complete. Pick a shipment day and proceed to operation.",
|
||||||
stage: 5,
|
stage: 2,
|
||||||
},
|
},
|
||||||
OPERATION_REQUESTED: {
|
OPERATION_REQUESTED: {
|
||||||
title: "Operation requested",
|
title: "Operation requested",
|
||||||
description: "Operation requested. An operator will take your shipment forward.",
|
description: "Operation requested. An operator will take your shipment forward.",
|
||||||
stage: 5,
|
stage: 4,
|
||||||
},
|
},
|
||||||
CONTRACT_ACTIVE: {
|
CONTRACT_ACTIVE: {
|
||||||
title: "Contract active",
|
title: "Contract active",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Button, Group, Modal, Text } from "@mantine/core";
|
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";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ function BookingActionModalBody({
|
|||||||
}) {
|
}) {
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
const flow = useClearanceFlow(booking);
|
const flow = useClearanceFlow(booking);
|
||||||
|
const navigate = useNavigate();
|
||||||
const reference = booking.reference;
|
const reference = booking.reference;
|
||||||
|
|
||||||
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
|
const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose });
|
||||||
@@ -91,17 +93,31 @@ function BookingActionModalBody({
|
|||||||
Submit documents
|
Submit documents
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{flow.isReady && (
|
{flow.needsCompletion && flow.completeTo ? (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<PackagePlus size={16} />}
|
||||||
onClick={handleProceed}
|
onClick={() => {
|
||||||
loading={flow.proceedMutation.isPending}
|
onClose();
|
||||||
disabled={!flow.scheduledDate}
|
navigate(flow.completeTo!);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Proceed to operation
|
Complete booking
|
||||||
</Button>
|
</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>
|
</Group>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
customerDocs,
|
customerDocs,
|
||||||
glDocs,
|
glDocs,
|
||||||
isReady,
|
isReady,
|
||||||
|
needsCompletion,
|
||||||
canUpload,
|
canUpload,
|
||||||
isInitialUpload,
|
isInitialUpload,
|
||||||
status,
|
status,
|
||||||
@@ -78,9 +79,11 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{isReady ? (
|
{isReady ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
{clearance.includesCustoms
|
{needsCompletion
|
||||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
? "Clearance is finalized. Complete your booking now — enter the cargo details and pick a shipment day inside an open booking window."
|
||||||
: "Clearance is ready. You can now proceed to operation."}
|
: 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>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
@@ -208,7 +211,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReady && (
|
{isReady && !needsCompletion && (
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
<Text fz="13px" fw={700} c="#10202F" mb={6}>
|
||||||
Choose your shipment day
|
Choose your shipment day
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isReady = status === "CLEARANCE_READY";
|
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 =
|
const canUpload =
|
||||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
// The very first upload (nothing in review yet). Here every required document
|
// The very first upload (nothing in review yet). Here every required document
|
||||||
@@ -146,6 +156,8 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
customerDocs,
|
customerDocs,
|
||||||
glDocs,
|
glDocs,
|
||||||
isReady,
|
isReady,
|
||||||
|
needsCompletion,
|
||||||
|
completeTo,
|
||||||
canUpload,
|
canUpload,
|
||||||
isInitialUpload,
|
isInitialUpload,
|
||||||
// staged upload state
|
// staged upload state
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { labelForDocCode } from "@/pages/bookings/resubmit";
|
|||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||||
@@ -348,6 +349,9 @@ export default function ContractDetailPage() {
|
|||||||
const canBookShipment =
|
const canBookShipment =
|
||||||
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
bookingAction.kind === "book" || bookingAction.kind === "rebook";
|
||||||
const canRequestShipment = bookingAction.kind === "request";
|
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
|
// Customs + clearance finalized: GL is preparing the booking — surface a
|
||||||
// status notice instead of any action.
|
// status notice instead of any action.
|
||||||
const glPreparingBooking = customsPath && clearanceFinalized;
|
const glPreparingBooking = customsPath && clearanceFinalized;
|
||||||
@@ -438,6 +442,13 @@ export default function ContractDetailPage() {
|
|||||||
Request shipment
|
Request shipment
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canInitiateBooking && (
|
||||||
|
<InitiateBookingButton
|
||||||
|
contract={contract}
|
||||||
|
icon={PackagePlus}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canBookShipment &&
|
{canBookShipment &&
|
||||||
(bookingWindowOpen ? (
|
(bookingWindowOpen ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1289,6 +1300,13 @@ export default function ContractDetailPage() {
|
|||||||
>
|
>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
<SectionLabel>Bookings under this contract</SectionLabel>
|
<SectionLabel>Bookings under this contract</SectionLabel>
|
||||||
|
{canInitiateBooking && (
|
||||||
|
<InitiateBookingButton
|
||||||
|
contract={contract}
|
||||||
|
icon={PackagePlus}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canBookShipment && bookingWindowOpen && (
|
{canBookShipment && bookingWindowOpen && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function NewShipmentPage() {
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery(
|
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(
|
function bulkUnitOfMeasure(
|
||||||
@@ -219,9 +230,12 @@ function bulkUnitOfMeasure(
|
|||||||
function NewShipmentBookingForm({
|
function NewShipmentBookingForm({
|
||||||
contract,
|
contract,
|
||||||
contractId,
|
contractId,
|
||||||
|
completeBookingId,
|
||||||
}: {
|
}: {
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
/** Set when completing an initiated (bare) booking after clearance. */
|
||||||
|
completeBookingId?: string;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -251,7 +265,13 @@ function NewShipmentBookingForm({
|
|||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
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) => {
|
onSuccess: (booking) => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -368,10 +388,12 @@ function NewShipmentBookingForm({
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
New Shipment Booking
|
{completeBookingId ? "Complete Your Booking" : "New Shipment Booking"}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="edr-muted" mt={4}>
|
<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>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
@@ -398,7 +420,9 @@ function NewShipmentBookingForm({
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={600}>
|
<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>
|
||||||
<Text size="sm" mt={4} c="red.7">
|
<Text size="sm" mt={4} c="red.7">
|
||||||
{submitMutation.error instanceof Error
|
{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. */
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
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 {
|
export interface ContractBookingAction {
|
||||||
kind: ContractBookingActionKind;
|
kind: ContractBookingActionKind;
|
||||||
@@ -63,5 +68,13 @@ export function getContractBookingAction(
|
|||||||
return { kind: hasExpired ? "rebook" : "book", to };
|
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 };
|
return { kind: "book", to };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,6 +531,24 @@ export const api = {
|
|||||||
contractsService.createBookingUnderContract(id, dto),
|
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<
|
validateShipment: endpoint<
|
||||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||||
ShipmentValidation
|
ShipmentValidation
|
||||||
|
|||||||
@@ -329,6 +329,32 @@ export const contractsService = {
|
|||||||
return data.data.booking ?? data.data;
|
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
|
* Pre-submit validation of a shipment booking (same DTO as
|
||||||
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||||
|
|||||||
Reference in New Issue
Block a user