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:
marshal
2026-09-06 12:33:40 +00:00
parent f225721e89
commit 75b75e3d4e
32 changed files with 1582 additions and 179 deletions

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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,

View File

@@ -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",

View File

@@ -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.

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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.