mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +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.
|
||||
|
||||
Reference in New Issue
Block a user