mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge pull request #1461 from Tria-plc/freight_feature/usermanagement
feat(contracts): implement staff cancellation of contracts with reaso…
This commit is contained in:
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Eye,
|
||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
|
||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||
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);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
|
||||
const [suspendReason, setSuspendReason] = useState("");
|
||||
const [resumeOpen, setResumeOpen] = useState(false);
|
||||
const [resumeNote, setResumeNote] = useState("");
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||
const cancelModal = (
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
title="Cancel this contract?"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Contract <b>{contract.reference}</b> will be cancelled permanently.
|
||||
This cannot be undone — there is no way to reactivate it. A new
|
||||
contract with the same details can be submitted afterwards. The
|
||||
customer is notified.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for cancellation"
|
||||
placeholder="Explain why this contract is being cancelled…"
|
||||
autosize
|
||||
minRows={3}
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setCancelOpen(false)}>
|
||||
Keep contract
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!cancelReason.trim()}
|
||||
loading={mutations.cancelByStaff.isPending}
|
||||
onClick={() =>
|
||||
mutations.cancelByStaff.mutate(cancelReason, {
|
||||
onSuccess: () => {
|
||||
setCancelOpen(false);
|
||||
setCancelReason("");
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Cancel contract
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const cancelButton = mayCancel ? (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel contract
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// Whether the document is editable depends on WHO is viewing — only the
|
||||
// approver whose turn it is may edit — so the server decides, not the client.
|
||||
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until the customer resubmits the contract.
|
||||
</Text>
|
||||
{cancelButton}
|
||||
</Stack>
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
|
||||
You do not have permission to lift a suspension.
|
||||
</Text>
|
||||
)}
|
||||
{cancelButton}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Available at every non-terminal status — the early returns above
|
||||
already cover the statuses where cancelling makes no sense. */}
|
||||
{cancelButton}
|
||||
|
||||
{!canAccept &&
|
||||
!inApproval &&
|
||||
!canViewContract &&
|
||||
!canReviewClearance &&
|
||||
!canSuspend && (
|
||||
!canSuspend &&
|
||||
!mayCancel && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{cancelModal}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/contracts/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
|
||||
@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
|
||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||
});
|
||||
|
||||
const cancelByStaff = useMutation({
|
||||
mutationFn: (reason: string) =>
|
||||
contractsService.cancelByStaff(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract cancelled"),
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Failed to cancel contract")),
|
||||
});
|
||||
|
||||
const suspend = useMutation({
|
||||
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
|
||||
updateDocument,
|
||||
requestChanges,
|
||||
reject,
|
||||
cancelByStaff,
|
||||
suspend,
|
||||
resume,
|
||||
approveStep,
|
||||
|
||||
@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||
suspend: "edr_freight_app:contracts:suspend",
|
||||
cancel: "edr_freight_app:contracts:cancel",
|
||||
editDocument: "edr_freight_app:contracts:edit_document",
|
||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||
|
||||
@@ -288,6 +288,13 @@ export const contractsService = {
|
||||
reject: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||
|
||||
/**
|
||||
* Cancel a contract outright. Terminal — unlike {@link suspend} there is no
|
||||
* way back; a new contract with the same details must be submitted instead.
|
||||
*/
|
||||
cancelByStaff: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.STAFF_CANCEL(id), { reason }),
|
||||
|
||||
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
||||
suspend: (id: string, reason: string) =>
|
||||
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
||||
|
||||
Reference in New Issue
Block a user