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.