feat: implement booking action logic for contracts and enhance RecentContractsSection

This commit is contained in:
Marshal
2026-06-28 18:50:50 +00:00
parent a34e846d90
commit 258f1eb45a
6 changed files with 100 additions and 23 deletions

View File

@@ -96,6 +96,7 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 6 }}>
<RecentContractsSection
contracts={recentContracts}
bookings={allBookings}
isLoading={contractsQuery.isPending}
/>
</Grid.Col>

View File

@@ -1,12 +1,13 @@
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { memo } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
import { ArrowRight, FileSignature, Package, Plus, RefreshCw } from "lucide-react";
import type { Freight } from "@edr/types";
import {
ContractDocButton,
ContractStatusBadge,
} from "@/pages/contracts/contract-ui";
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
import { Card } from "./Card";
import { EmptyState } from "./EmptyState";
@@ -14,16 +15,15 @@ const INK = "#10202F";
const MUTED = "#6B7C8E";
const BORDER = "#E6ECF2";
// Statuses where a Path A customer may book directly against the contract.
const BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
interface RecentContractsSectionProps {
contracts: Freight.IContract[];
bookings: Freight.IBooking[];
isLoading: boolean;
}
export const RecentContractsSection = memo(function RecentContractsSection({
contracts,
bookings,
isLoading,
}: RecentContractsSectionProps) {
const navigate = useNavigate();
@@ -65,7 +65,7 @@ export const RecentContractsSection = memo(function RecentContractsSection({
const isGeneral = c.contractKind === "GENERAL";
const isContainer = c.freightType === "CONTAINER";
const canSign = c.status === "CONTRACT_READY";
const canBook = !c.customsClearingEnabled && BOOKABLE.includes(c.status);
const bookingAction = getContractBookingAction(c, bookings);
return (
<Group
key={c.id}
@@ -109,18 +109,24 @@ export const RecentContractsSection = memo(function RecentContractsSection({
>
Sign
</Button>
) : canBook ? (
) : bookingAction.kind !== "none" ? (
<Button
size="xs"
radius="md"
color="edr-green"
leftSection={<Package size={13} />}
leftSection={
bookingAction.kind === "rebook" ? (
<RefreshCw size={13} />
) : (
<Package size={13} />
)
}
onClick={(e) => {
e.stopPropagation();
navigate(`/contracts/${c.id}/bookings/new`);
navigate(bookingAction.to);
}}
>
Book
{bookingAction.kind === "rebook" ? "Re-book" : "Book"}
</Button>
) : (
<Button

View File

@@ -40,6 +40,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const [payModalOpen, setPayModalOpen] = useState(false);
const { view, viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the
// New Contract page. Fall back to /contracts/new only if the link is missing.
const rebookTo = booking.contractId
? `/contracts/${booking.contractId}/bookings/new`
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// POST /payments/initiate creates the intent and returns the provider's
// redirect URL (clientAction.url). Send the browser straight there; fall back
// to the public /payments/checkout page if no redirect URL came back.
@@ -98,7 +105,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
}
menuActions={{
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
onRebook: () => navigate("/bookings/new", { state: { fresh: true } }),
onRebook,
onSupport: () => navigate("/support"),
}}
/>
@@ -115,14 +122,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
onRebook={onRebook}
/>
) : isExpired ? (
<CancelledBanner
pillLabel="Expired"
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new", { state: { fresh: true } })}
onRebook={onRebook}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner

View File

@@ -32,6 +32,7 @@ import {
import { api } from "@/services/api";
import type { ContractListFilter } from "@/services/contracts.service";
import type { Freight } from "@edr/types";
import { getContractBookingAction } from "./contract-booking-action";
import { usePagination } from "@edr/ui-common";
import {
BORDER,
@@ -52,7 +53,6 @@ function primaryRoute(contract: Freight.IContract) {
};
}
const PATH_A_BOOKABLE_STATUSES = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
const PATH_B_CLEARANCE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
@@ -62,6 +62,7 @@ const PATH_B_CLEARANCE_STATUSES = [
/** The single most relevant next action for a customer's contract row. */
function getCustomerRowAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): { label: string; to: string; primary: boolean } {
const id = contract.id;
if (contract.status === "CONTRACT_READY") {
@@ -80,15 +81,12 @@ function getCustomerRowAction(
primary: true,
};
}
if (
!contract.customsClearingEnabled &&
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
) {
return {
label: "Book shipment",
to: `/contracts/${id}/bookings/new`,
primary: true,
};
const booking = getContractBookingAction(contract, bookings);
if (booking.kind === "book") {
return { label: "Book shipment", to: booking.to, primary: true };
}
if (booking.kind === "rebook") {
return { label: "Re-book shipment", to: booking.to, primary: true };
}
return { label: "View", to: `/contracts/${id}`, primary: false };
}
@@ -139,6 +137,15 @@ export default function ContractsList() {
api.contracts.list.queryOptions({ input: filter }),
);
// Bookings have no embedded link on the contract list, so cross-reference
// them to decide Book / Re-book per row (see getContractBookingAction).
const { data: bookingsData } = useQuery(
api.bookings.list.queryOptions({
input: { sortBy: "createdAt", sortOrder: "DESC" },
}),
);
const bookings = bookingsData?.items ?? [];
const rows = useMemo(() => {
const items = data?.items ?? [];
if (!query.trim()) return items;
@@ -407,7 +414,7 @@ export default function ContractsList() {
const tradeLabel = dir
? dir.charAt(0) + dir.slice(1).toLowerCase()
: "—";
const action = getCustomerRowAction(c);
const action = getCustomerRowAction(c, bookings);
return (
<Table.Tr
key={c.id}

View File

@@ -0,0 +1,54 @@
import type { Freight } from "@edr/types";
/**
* Booking statuses that no longer occupy the single active-booking slot of a
* ONE_TIME contract. Mirrors the API source of truth in
* `contract-booking.service.ts`.
*/
export const TERMINAL_BOOKING_STATUSES = [
"EXPIRED",
"CANCELLED",
"COMPLETED",
"REJECTED",
];
/** 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" | "none";
export interface ContractBookingAction {
kind: ContractBookingActionKind;
to: string;
}
/**
* The single source of truth for whether a contract row should show a
* Book / Re-book shipment button, and where it should navigate.
*
* - ONE_TIME: book only while there is no active booking. If the previous
* booking expired, offer Re-book. Once a live booking exists, no button.
* - GENERAL: bookable while CONTRACT_ACTIVE (CONTRACT_CLOSED / EXPIRED are
* already excluded by the PATH_A_BOOKABLE gate).
*/
export function getContractBookingAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): ContractBookingAction {
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
const to = `/contracts/${contract.id}/bookings/new`;
if (contract.contractKind === "ONE_TIME") {
const mine = bookings.filter((b) => b.contractId === contract.id);
const hasActive = mine.some(
(b) => !TERMINAL_BOOKING_STATUSES.includes(b.status),
);
if (hasActive) return { kind: "none", to: "" };
const hasExpired = mine.some((b) => b.status === "EXPIRED");
return { kind: hasExpired ? "rebook" : "book", to };
}
return { kind: "book", to };
}

View File

@@ -354,6 +354,8 @@ export interface IYard extends BaseEntity {
export interface IBooking extends BaseEntity {
reference: string;
customerId: string;
/** The contract this booking was created under (Path A / Path B). */
contractId?: string | null;
trainId?: string | null;
status: BookingStatus;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */