implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers

This commit is contained in:
Marshal
2026-07-15 20:14:39 +00:00
parent d7d9db9c3a
commit fc44eee25e
44 changed files with 970 additions and 14 deletions

View File

@@ -14,6 +14,7 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { api } from "@/services/api";
import { ContractClearanceAction } from "./ContractClearanceAction";
@@ -69,6 +70,17 @@ export function ContractCustomerAction({
);
}
if (action.type === "pay-clearance") {
return (
<PayClearanceFeeButton
sourceId={action.contractId}
currency={contract.paymentCurrency}
label={action.label}
size={size}
/>
);
}
if (action.type === "initiate") {
return (
<InitiateBookingButton

View File

@@ -80,6 +80,14 @@ export type ContractCustomerAction =
label: string;
primary: boolean;
icon: LucideIcon;
}
| {
/** Prepaid customs clearance service fee (contract-level, ONE_TIME Path B). */
type: "pay-clearance";
contractId: string;
label: string;
primary: boolean;
icon: LucideIcon;
};
function findPayableBookingForContract(
@@ -137,6 +145,17 @@ export function deriveContractCustomerAction(
};
}
// Prepaid clearance service fee gate — must settle before document upload.
if (contract.status === "AWAITING_CLEARANCE_PAYMENT") {
return {
type: "pay-clearance",
contractId: id,
label: "Pay clearance fee",
primary: true,
icon: CreditCard,
};
}
const payable = findPayableBookingForContract(id, bookings);
if (payable) {
return {

View File

@@ -6,7 +6,7 @@ import { contractNeedsClearanceAction } from "@/components/customer-actions/deri
export interface ActionItem {
id: string;
/** What the customer must do — drives the icon, label and modal. */
kind: "clearance" | "duty" | "sign" | "book" | "pay";
kind: "clearance" | "duty" | "sign" | "book" | "pay" | "clearance-fee";
/** The contract/booking reference for display. */
reference: string;
/** Short human description of the action. */
@@ -40,6 +40,18 @@ export function deriveActionItems(
});
continue;
}
// Prepaid clearance service fee (Path B) — blocks the document step.
if (c.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${c.id}`,
kind: "clearance-fee",
reference: c.reference,
description: "Clearance service fee due — pay to unlock document upload",
targetId: c.id,
urgent: true,
});
continue;
}
const clr = contractNeedsClearanceAction(c);
if (clr.show) {
items.push({
@@ -70,6 +82,19 @@ export function deriveActionItems(
}
for (const b of bookings) {
// Per-shipment clearance service fee (GENERAL + customs shipment request).
if (b.status === "AWAITING_CLEARANCE_PAYMENT") {
items.push({
id: `clearance-fee-${b.id}`,
kind: "clearance-fee",
reference: b.reference,
description:
"Clearance service fee due for this shipment — pay to unlock document upload",
targetId: b.id,
urgent: true,
});
continue;
}
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
const canPay =
b.paymentStatus !== "PAID" &&

View File

@@ -39,6 +39,7 @@ const KIND_META: Record<
sign: { icon: FileSignature, label: "Sign", color: "blue" },
book: { icon: PackagePlus, label: "Book", color: "violet" },
pay: { icon: CreditCard, label: "Payment", color: "orange" },
"clearance-fee": { icon: CreditCard, label: "Clearance fee", color: "orange" },
};
export interface ActionNeededSectionProps {
@@ -137,9 +138,14 @@ export function ActionNeededSection({
// Billing is invoice-centric — resolve the booking's currently payable
// invoice before paying it (mirrors ReadonlyBookingView).
// A "pay" item settles the booking invoice; a "clearance-fee" item settles the
// prepaid clearance-fee invoice (source `clearance`, keyed by contract or
// booking id depending on where the gate sits).
const payItemSource = payItem?.kind === "clearance-fee" ? "clearance" : "booking";
const { data: payItemInvoices = [] } = useQuery({
queryKey: ["booking-invoices", payItem?.targetId],
queryFn: () => invoicesService.listForSource("booking", payItem!.targetId),
queryKey: [`${payItemSource}-invoices`, payItem?.targetId],
queryFn: () =>
invoicesService.listForSource(payItemSource, payItem!.targetId),
enabled: payItem !== null,
});
const payableInvoiceId = payItemInvoices.find((inv) =>
@@ -182,6 +188,7 @@ export function ActionNeededSection({
navigate(`/contracts/${item.targetId}`);
break;
case "pay":
case "clearance-fee":
setPayItem(item);
break;
case "sign":
@@ -277,7 +284,9 @@ export function ActionNeededSection({
>
{item.kind === "pay"
? "Pay now"
: item.kind === "duty"
: item.kind === "clearance-fee"
? "Pay clearance fee"
: item.kind === "duty"
? "Pay duty & upload slip"
: item.kind === "sign"
? "Sign"

View File

@@ -165,6 +165,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
AWAITING_CLEARANCE_PAYMENT: {
stage: 3,
icon: Wallet,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Clearance service fee due · pay to unlock document upload",
step: "edr-accent",
badgeLabel: "Clearance fee due",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight },
},
AWAITING_DOCUMENTS: {
stage: 3,
icon: FileUp,

View File

@@ -1,4 +1,4 @@
import { Group, Tabs } from "@mantine/core";
import { Group, Paper, Tabs, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
@@ -12,6 +12,7 @@ import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton";
import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard";
import { DocumentsTab } from "./components/DocumentsTab";
@@ -139,6 +140,8 @@ export function ReadonlyBookingView({
const isCustoms = Boolean(booking.customsClearingEnabled);
const canSelfRebook = !isCustoms;
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Prepaid clearance service fee gate — document upload stays locked until paid.
const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT";
const isClearance = [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
@@ -237,6 +240,28 @@ export function ReadonlyBookingView({
<div className="flex flex-col gap-6">
<ContractCard booking={booking} />
{isAwaitingClearanceFee && (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: "#FDE68A", background: "#FFFBEB" }}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<div>
<Text fw={700} fz={15} c="#92400E">
Customs clearance service fee due
</Text>
<Text fz={13} c="#B45309" mt={4}>
Pay the clearance service fee to unlock the clearance
document upload. Global Logistics starts working on your
shipment once the fee is settled.
</Text>
</div>
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
size="md"
/>
</Group>
</Paper>
)}
{isClearance && <ClearanceCard booking={booking} />}
<BodyGrid

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import {
AlertCircle,
ArrowRight,
CreditCard,
PackagePlus,
PencilLine,
Upload,
@@ -11,6 +12,7 @@ import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal";
import { BookingActionModal } from "./BookingActionModal";
@@ -23,6 +25,7 @@ const ICON_BY_KIND: Record<
BookingActionKind,
typeof Upload
> = {
PAY_CLEARANCE: CreditCard,
UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight,
@@ -56,6 +59,19 @@ export function BookingActionButton({
if (!isChangesRequested && !action) return null;
// The prepaid clearance service fee has its own payment flow (method modal +
// provider redirect) — delegate to the self-contained pay button.
if (action?.kind === "PAY_CLEARANCE") {
return (
<PayClearanceFeeButton
sourceId={booking.id}
currency={booking.paymentCurrency}
label={action.label}
size={size}
/>
);
}
const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine;
const label = action ? action.label : "Update & resubmit";
// BOOK navigates to the booking form (cargo + day + window check) — the

View File

@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
* to operation.
*/
export type BookingActionKind =
| "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed
@@ -23,6 +24,11 @@ export interface BookingNextAction {
}
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
AWAITING_CLEARANCE_PAYMENT: {
kind: "PAY_CLEARANCE",
label: "Pay clearance fee",
title: "Pay the clearance service fee",
},
AWAITING_DOCUMENTS: {
kind: "UPLOAD_DOCUMENTS",
label: "Upload documents",

View File

@@ -0,0 +1,133 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard } from "lucide-react";
import { useState } from "react";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { isPayable } from "@/pages/billing/invoice-ui";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
/**
* Payment flow for the prepaid customs clearance service fee. The fee is its
* own `clearance`-source invoice — sourceId is the contract id (ONE_TIME,
* contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL
* shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it
* unlocks the clearance document upload; same modal + provider redirect as
* booking payment.
*/
export function useClearanceFeePayment(sourceId: string) {
const [modalOpen, setModalOpen] = useState(false);
const { data: invoices = [] } = useQuery({
queryKey: ["clearance-invoices", sourceId],
queryFn: () => invoicesService.listForSource("clearance", sourceId),
enabled: Boolean(sourceId),
});
const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null;
const mutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoice) {
throw new Error(
"No payable clearance-fee invoice found yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoice.id,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoice!.id,
method,
});
window.location.href = redirectUrl;
},
});
const close = () => {
if (!mutation.isPending) {
setModalOpen(false);
mutation.reset();
}
};
return {
invoice: payableInvoice,
modalOpen,
open: () => setModalOpen(true),
close,
processing: mutation.isPending,
error: mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null,
confirm: (method: PaymentMethod) => mutation.mutate(method),
};
}
interface PayClearanceFeeButtonProps {
/** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */
sourceId: string;
/** Fallback currency while the invoice is loading. */
currency?: string;
label?: string;
size?: ButtonProps["size"];
fullWidth?: boolean;
}
/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */
export function PayClearanceFeeButton({
sourceId,
currency,
label = "Pay clearance fee",
size = "xs",
fullWidth,
}: PayClearanceFeeButtonProps) {
const pay = useClearanceFeePayment(sourceId);
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
fullWidth={fullWidth}
leftSection={<CreditCard size={14} />}
onClick={(e) => {
e.stopPropagation();
pay.open();
}}
>
{label}
</Button>
<PaymentMethodModal
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={
pay.invoice
? `${Number(pay.invoice.totalAmount).toLocaleString()} ${pay.invoice.currency}`
: undefined
}
currency={pay.invoice?.currency ?? currency}
processing={pay.processing}
error={pay.error}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>
);
}

View File

@@ -72,6 +72,7 @@ import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
@@ -396,6 +397,9 @@ export default function ContractDetailPage() {
// clearance is finalized.
const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
// Prepaid clearance service fee gate (Path B) — the document step stays
// locked until the fee invoice settles.
const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT";
return (
<Box style={{ padding: "28px 32px 40px" }}>
@@ -531,6 +535,13 @@ export default function ContractDetailPage() {
Global Logistics is creating your booking
</Badge>
)}
{awaitingClearanceFee && (
<PayClearanceFeeButton
sourceId={contract.id}
currency={contract.paymentCurrency}
size="md"
/>
)}
{canUploadClearance && (
<Button
color="edr-green"
@@ -875,6 +886,12 @@ export default function ContractDetailPage() {
{item.containerSize}
</Text>
)}
{item.isClearance && (
<Text fz={12} c="orange.7" fw={600}>
Paid in advance, before clearance excluded from
shipment invoices
</Text>
)}
</Box>
<Text fz={14} fw={700} style={{ color: GREEN }}>
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}

View File

@@ -1005,6 +1005,12 @@ export default function NewContractPage({
{item.containerSize}
</Text>
)}
{item.isClearance && (
<Text size="xs" c="orange.7" fw={600}>
Paid in advance, before clearance not part of your
shipment booking invoice
</Text>
)}
</Box>
<Text
size="sm"

View File

@@ -51,10 +51,11 @@ export default function NewShipmentRequestPage() {
contractsService.submitBookingRequest(id!, dto),
onSuccess: (request) => {
// Clearance-first flow: the request auto-initiates a booking instance —
// send the customer straight to it to upload clearance documents.
// send the customer straight to it. The clearance service fee is due
// first; document upload unlocks once it settles.
if (request.createdBookingId) {
toast.success(
"Shipment initiated — upload your clearance documents to start the review.",
"Shipment initiated — pay the clearance service fee to unlock the document upload.",
);
navigate(`/bookings/${request.createdBookingId}`);
} else {

View File

@@ -125,6 +125,10 @@ export const CONTRACT_STATUS_CONFIG: Record<
FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success },
CONTRACT_ACTIVE: { label: "Active", ...TONE.success },
// ── Path B pre-booking clearance (contract-level) ──
AWAITING_CLEARANCE_PAYMENT: {
label: "Clearance Fee Due",
...TONE.warning,
},
AWAITING_CLEARANCE_DOCUMENTS: {
label: "Upload Clearance Docs",
...TONE.warning,