mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
feat: add contract extension request functionality
- Implemented method in to allow customers to request an extension for expired contracts. - Added component in for users to initiate extension requests. - Updated to include logic for handling extension requests for expired contracts. - Enhanced to display extension request options and status. - Created migration to add and columns to the contracts table. - Added unit tests for contract extension request and handling in . - Defined DTOs for request and extension in . - Updated types in to include new fields related to contract extensions.
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
Check,
|
||||
Eye,
|
||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||
@@ -84,6 +94,9 @@ export function ContractActionsToolbar({
|
||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||
// Revive an EXPIRED contract by adding validity days — only after the
|
||||
// customer asked for it from the portal (the API enforces the same).
|
||||
const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
@@ -98,6 +111,9 @@ export function ContractActionsToolbar({
|
||||
const [resumeNote, setResumeNote] = useState("");
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [extendOpen, setExtendOpen] = useState(false);
|
||||
const [extendDays, setExtendDays] = useState<number>(30);
|
||||
const [extendNote, setExtendNote] = useState("");
|
||||
|
||||
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||
const cancelModal = (
|
||||
@@ -183,7 +199,132 @@ export function ContractActionsToolbar({
|
||||
[validitySetting],
|
||||
);
|
||||
|
||||
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
// Lapsed: nothing to do until the customer asks for more time from the
|
||||
// portal. Once they have, staff add days and the contract returns to the
|
||||
// status it held before it expired.
|
||||
if (status === "EXPIRED") {
|
||||
const requestedAt = contract.extensionRequestedAt
|
||||
? new Date(contract.extensionRequestedAt)
|
||||
: null;
|
||||
const currentEnd = contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil)
|
||||
: null;
|
||||
// Mirrors ContractTransitionService.extend: days count from today once the
|
||||
// contract has lapsed, from the current end date otherwise.
|
||||
const base =
|
||||
currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date();
|
||||
const newEnd = new Date(base);
|
||||
newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0)));
|
||||
const restoredStatus =
|
||||
contract.statusBeforeExpiry ??
|
||||
(contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED");
|
||||
const daysValid = Number.isInteger(extendDays) && extendDays >= 1;
|
||||
|
||||
return (
|
||||
<SectionCard icon={CalendarClock} title="Contract expired">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
This contract's validity ended
|
||||
{currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New
|
||||
bookings are blocked until it is extended.
|
||||
</Text>
|
||||
{requestedAt ? (
|
||||
<>
|
||||
<Text size="sm">
|
||||
<b>Extension requested</b> by the customer on{" "}
|
||||
{requestedAt.toLocaleDateString()}.
|
||||
</Text>
|
||||
{contract.latestExtensionRequestNote && (
|
||||
<Text size="sm">
|
||||
<b>Reason:</b> {contract.latestExtensionRequestNote}
|
||||
</Text>
|
||||
)}
|
||||
{mayExtend ? (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<CalendarPlus size={16} />}
|
||||
onClick={() => setExtendOpen(true)}
|
||||
>
|
||||
Extend contract
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
You do not have permission to extend a contract.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
The customer has not requested an extension. A contract can only
|
||||
be extended once they ask for it from the portal.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={extendOpen}
|
||||
onClose={() => setExtendOpen(false)}
|
||||
title="Extend this contract?"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Contract <b>{contract.reference}</b> gets the days below added to
|
||||
its validity, returns to <b>{restoredStatus}</b>, and the customer
|
||||
is notified. Bookings under it are possible again immediately.
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Days to add"
|
||||
min={1}
|
||||
max={3650}
|
||||
step={1}
|
||||
allowDecimal={false}
|
||||
value={extendDays}
|
||||
onChange={(v) => setExtendDays(typeof v === "number" ? v : Number(v) || 0)}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
New validity end:{" "}
|
||||
<b>{daysValid ? newEnd.toLocaleDateString() : "—"}</b>
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Note (optional)"
|
||||
placeholder="Shown to the customer with the extension…"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={extendNote}
|
||||
onChange={(e) => setExtendNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setExtendOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={!daysValid}
|
||||
loading={mutations.extend.isPending}
|
||||
onClick={() =>
|
||||
mutations.extend.mutate(
|
||||
{ days: extendDays, note: extendNote.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setExtendOpen(false);
|
||||
setExtendNote("");
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Extend contract
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (["REJECTED", "CANCELLED", "CONTRACT_CLOSED"].includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ export const URL_CONSTANTS = {
|
||||
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||
EXTEND: (id: string) => `/contracts/${id}/extend`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
|
||||
@@ -172,6 +172,14 @@ export function useContractMutations(contractId: string) {
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
|
||||
});
|
||||
|
||||
const extend = useMutation({
|
||||
mutationFn: (payload: Freight.ExtendContractDto) =>
|
||||
contractsService.extend(contractId, payload),
|
||||
onSuccess: (data) =>
|
||||
onSuccess(data, `Contract extended — it is back to ${data.status}`),
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to extend contract")),
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
// The server derives the required role from the step itself, so the client
|
||||
// does not send one.
|
||||
@@ -280,6 +288,7 @@ export function useContractMutations(contractId: string) {
|
||||
cancelByStaff,
|
||||
suspend,
|
||||
resume,
|
||||
extend,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
|
||||
@@ -97,6 +97,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
extend: "edr_freight_app:contracts:extend",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
|
||||
@@ -303,6 +303,14 @@ export const contractsService = {
|
||||
resume: (id: string, note?: string) =>
|
||||
postContract<Freight.IContract>(C.RESUME(id), { note }),
|
||||
|
||||
/**
|
||||
* Add validity days to an EXPIRED contract the customer asked to extend; it
|
||||
* returns to the status it held before it lapsed. The API refuses it while
|
||||
* no customer request is pending.
|
||||
*/
|
||||
extend: (id: string, payload: Freight.ExtendContractDto) =>
|
||||
postContract<Freight.IContract>(C.EXTEND(id), payload),
|
||||
|
||||
/**
|
||||
* Approve the next pending step. The server resolves the step's required role
|
||||
* and authorizes against it — the client never declares its own role.
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
type ButtonProps,
|
||||
} from "@mantine/core";
|
||||
@@ -15,6 +16,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||
|
||||
@@ -61,6 +63,18 @@ export function ContractCustomerAction({
|
||||
);
|
||||
}
|
||||
|
||||
if (action.type === "requestExtension") {
|
||||
return (
|
||||
<RequestExtensionButton
|
||||
contract={action.contract}
|
||||
label={action.label}
|
||||
icon={action.icon}
|
||||
size={size}
|
||||
listStyle={listStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = action.icon;
|
||||
const variant = action.primary ? "filled" : "light";
|
||||
|
||||
@@ -226,6 +240,156 @@ export function InitiateBookingButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask EDR to extend an EXPIRED contract. Nothing changes on the contract until
|
||||
* staff add validity days on their side — this only records the request (and
|
||||
* an optional reason) and notifies the contract desk.
|
||||
*/
|
||||
export function RequestExtensionButton({
|
||||
contract,
|
||||
label = "Request extension",
|
||||
icon: Icon,
|
||||
size = "xs",
|
||||
listStyle = false,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
label?: string;
|
||||
icon: LucideIcon;
|
||||
size?: ButtonProps["size"];
|
||||
listStyle?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.requestExtension(contract.id, note.trim() || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: api.contracts.listMy.queryKey() });
|
||||
toast.success(
|
||||
"Extension requested — EDR will review it and extend the contract.",
|
||||
);
|
||||
setConfirmOpen(false);
|
||||
setNote("");
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const data = (
|
||||
e as { response?: { data?: { message?: string | string[] } } }
|
||||
).response?.data;
|
||||
const message = Array.isArray(data?.message)
|
||||
? data.message.join(", ")
|
||||
: data?.message;
|
||||
toast.error(message || e.message || "Could not request the extension");
|
||||
},
|
||||
});
|
||||
|
||||
const validUntil = contract.contractValidUntil
|
||||
? new Date(contract.contractValidUntil).toLocaleDateString()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={() => {
|
||||
if (!mutation.isPending) setConfirmOpen(false);
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
size="md"
|
||||
closeOnClickOutside={!mutation.isPending}
|
||||
closeOnEscape={!mutation.isPending}
|
||||
withCloseButton={!mutation.isPending}
|
||||
title={
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Request a contract extension?</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Contract{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
{validUntil ? `expired on ${validUntil}` : "has expired"}. EDR will
|
||||
review your request and add validity days; the contract becomes active
|
||||
again as soon as they do, and you will be notified.
|
||||
</Text>
|
||||
<Textarea
|
||||
mt="md"
|
||||
label="Reason (optional)"
|
||||
placeholder="Why do you need this contract extended?"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm" mt="lg">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Icon size={16} />}
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Send request
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<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();
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarPlus,
|
||||
Eye,
|
||||
FileSignature,
|
||||
PackagePlus,
|
||||
@@ -32,6 +33,14 @@ export type ContractCustomerAction =
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
| {
|
||||
/** Ask EDR to extend an EXPIRED contract — mutation with a reason, not navigation. */
|
||||
type: "requestExtension";
|
||||
contract: Freight.IContract;
|
||||
label: string;
|
||||
primary: boolean;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
/** Single best customer action for a contract row (list / home). */
|
||||
@@ -73,6 +82,27 @@ export function deriveContractCustomerAction(
|
||||
};
|
||||
}
|
||||
|
||||
// Lapsed: the customer's one move is to ask EDR for more time. Once asked,
|
||||
// the row just views until staff extend it (the API refuses a second ask).
|
||||
if (contract.status === "EXPIRED") {
|
||||
if (!contract.extensionRequestedAt) {
|
||||
return {
|
||||
type: "requestExtension",
|
||||
contract,
|
||||
label: "Request extension",
|
||||
primary: true,
|
||||
icon: CalendarPlus,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "navigate",
|
||||
label: "View",
|
||||
to: `/contracts/${id}`,
|
||||
primary: false,
|
||||
icon: Eye,
|
||||
};
|
||||
}
|
||||
|
||||
// Paying happens from the booking row/detail — contract rows never show
|
||||
// "Pay now" (payable bookings fall through to the next action here).
|
||||
if (
|
||||
|
||||
@@ -157,6 +157,7 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_SEND_SIGNING_OTP: (id: string) =>
|
||||
`/api/contracts/${id}/contract/send-signing-otp`,
|
||||
RENEW: (id: string) => `/api/contracts/${id}/renew`,
|
||||
EXTENSION_REQUEST: (id: string) => `/api/contracts/${id}/extension-request`,
|
||||
CANCEL: (id: string) => `/api/contracts/${id}/cancel`,
|
||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Download,
|
||||
@@ -62,7 +63,10 @@ import {
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "@/pages/bookings/booking-display";
|
||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import {
|
||||
InitiateBookingButton,
|
||||
RequestExtensionButton,
|
||||
} from "@/components/customer-actions/ContractCustomerAction";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { formatAmount } from "./new-shipment-form/total";
|
||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
@@ -391,6 +395,11 @@ export default function ContractDetailPage() {
|
||||
// Self-clearance import/export (ONE_TIME or GENERAL): one-click bare booking
|
||||
// instance — the per-booking clearance runs first, so no window gate here.
|
||||
const canInitiateBooking = bookingAction.kind === "initiate" && isContainer;
|
||||
// Lapsed contract: the customer asks for more time once; staff then add
|
||||
// validity days and the contract comes back. The API refuses a second ask.
|
||||
const extensionPending = Boolean(contract.extensionRequestedAt);
|
||||
const canRequestExtension =
|
||||
contract.status === "EXPIRED" && !extensionPending;
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -479,6 +488,13 @@ export default function ContractDetailPage() {
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canRequestExtension && (
|
||||
<RequestExtensionButton
|
||||
contract={contract}
|
||||
icon={CalendarPlus}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
{canBookShipment &&
|
||||
(bookingWindowOpen ? (
|
||||
<Button
|
||||
@@ -534,6 +550,28 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{contract.status === "EXPIRED" && (
|
||||
<Alert
|
||||
color={extensionPending ? "blue" : "red"}
|
||||
radius="md"
|
||||
title={
|
||||
extensionPending
|
||||
? "Extension requested — awaiting EDR"
|
||||
: "Contract expired"
|
||||
}
|
||||
>
|
||||
{extensionPending
|
||||
? `You asked EDR to extend this contract on ${new Date(
|
||||
contract.extensionRequestedAt!,
|
||||
).toLocaleDateString()}. Once EDR adds validity days it becomes active again and you will be notified.`
|
||||
: `This contract's validity ended${
|
||||
contract.contractValidUntil
|
||||
? ` on ${new Date(contract.contractValidUntil).toLocaleDateString()}`
|
||||
: ""
|
||||
}. New shipments cannot be booked under it. Request an extension and EDR will add validity days to make it active again.`}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{contract.status === "SUSPENDED" && (
|
||||
<Alert color="orange" radius="md" title="Contract suspended by EDR">
|
||||
{contract.latestSuspensionNote
|
||||
|
||||
@@ -290,6 +290,19 @@ export const contractsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask EDR to extend the validity of an EXPIRED contract. Staff then add days
|
||||
* on their side and the contract becomes active again. One pending request
|
||||
* at a time — the API rejects a second one.
|
||||
*/
|
||||
requestExtension: async (
|
||||
id: string,
|
||||
note?: string,
|
||||
): Promise<Freight.IContract> => {
|
||||
const { data } = await client.post(C.EXTENSION_REQUEST(id), { note });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel own contract so a fresh one can be requested on the same lane. The
|
||||
* API rejects it while any shipment on the contract is still live.
|
||||
|
||||
Reference in New Issue
Block a user