mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: implement booking action logic for contracts and enhance RecentContractsSection
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user