mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add reject step functionality to contract approval process
- Implemented rejection handling in ContractApprovalStepsCard with a modal for rejection reasons. - Updated useContractMutations to include rejectStep mutation. - Added REJECT_STEP URL constant for API integration. - Enhanced ContractClearanceDetailPage to utilize linkedBookingId for improved booking handling.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Check, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Button,
|
||||
Box,
|
||||
Modal,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -30,6 +31,10 @@ export function ContractApprovalStepsCard({
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectStepRow, setRejectStepRow] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
@@ -60,6 +65,28 @@ export function ContractApprovalStepsCard({
|
||||
);
|
||||
};
|
||||
|
||||
const openReject = (step: Freight.IContractApprovalStep) => {
|
||||
setRejectStepRow(step);
|
||||
setRejectReason("");
|
||||
setRejectOpen(true);
|
||||
};
|
||||
|
||||
const closeReject = () => {
|
||||
setRejectOpen(false);
|
||||
setRejectStepRow(null);
|
||||
setRejectReason("");
|
||||
};
|
||||
|
||||
const trimmedReason = rejectReason.trim();
|
||||
|
||||
const runReject = () => {
|
||||
if (!rejectStepRow || !trimmedReason) return;
|
||||
mutations.rejectStep.mutate(
|
||||
{ stepId: rejectStepRow.id, reason: trimmedReason },
|
||||
{ onSuccess: () => closeReject() },
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
@@ -106,8 +133,12 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
}
|
||||
onApprove={() => openApprove(step)}
|
||||
onReject={() => openReject(step)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -149,6 +180,54 @@ export function ContractApprovalStepsCard({
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={closeReject}
|
||||
title="Reject this step?"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Rejecting the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{rejectStepRow?.requiredRole}
|
||||
</Text>{" "}
|
||||
step rejects contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
outright. The customer must create a new contract — this cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
description="Shared with the customer and the approval chain."
|
||||
placeholder="Explain why this contract is rejected…"
|
||||
minRows={3}
|
||||
autosize
|
||||
withAsterisk
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={16} />}
|
||||
loading={mutations.rejectStep.isPending}
|
||||
disabled={!trimmedReason}
|
||||
onClick={runReject}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -158,11 +237,13 @@ function StepRow({
|
||||
isNext,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
@@ -222,15 +303,27 @@ function StepRow({
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<X size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
|
||||
@@ -162,6 +162,8 @@ export const URL_CONSTANTS = {
|
||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||
APPROVE_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
REJECT_STEP: (id: string, stepId: string) =>
|
||||
`/contracts/${id}/approval-steps/${stepId}/reject`,
|
||||
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||
|
||||
@@ -181,6 +181,15 @@ export function useContractMutations(contractId: string) {
|
||||
onError: () => toast.error("Failed to approve step"),
|
||||
});
|
||||
|
||||
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
|
||||
// the contract goes to REJECTED and the customer must create a new one.
|
||||
const rejectStep = useMutation({
|
||||
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
|
||||
contractsService.rejectStep({ id: contractId, stepId, reason }),
|
||||
onSuccess: (data) => onSuccess(data, "Contract rejected"),
|
||||
onError: () => toast.error("Failed to reject step"),
|
||||
});
|
||||
|
||||
// Manual fallback generate — used only if auto-generation failed.
|
||||
const generateContract = useMutation({
|
||||
mutationFn: () => contractsService.generateContract(contractId),
|
||||
@@ -210,6 +219,7 @@ export function useContractMutations(contractId: string) {
|
||||
requestChanges.isPending ||
|
||||
reject.isPending ||
|
||||
approveStep.isPending ||
|
||||
rejectStep.isPending ||
|
||||
generateContract.isPending ||
|
||||
signContract.isPending ||
|
||||
createBooking.isPending;
|
||||
@@ -219,6 +229,7 @@ export function useContractMutations(contractId: string) {
|
||||
requestChanges,
|
||||
reject,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
signContract,
|
||||
createBooking,
|
||||
|
||||
@@ -90,11 +90,16 @@ export default function ContractClearanceDetailPage() {
|
||||
].includes(contract.status),
|
||||
);
|
||||
const linkedBookingId = useMemo(() => {
|
||||
// Prefer the clearance view's server-resolved linkedBookingId (same field the
|
||||
// GL Djibouti page uses). The contract's clearanceCycles[cycle].bookingId can
|
||||
// be null/stale for an export FCFS booking, which would disable
|
||||
// useBookingMilestones → empty milestones → the export "Payment & wagon
|
||||
// allocation" step reads FREIGHT_PAYMENT_SETTLED as not-done and stays stuck.
|
||||
const cycle = contract?.clearanceCycles?.find(
|
||||
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||
);
|
||||
return cycle?.bookingId ?? undefined;
|
||||
}, [contract]);
|
||||
return clearance?.linkedBookingId ?? cycle?.bookingId ?? undefined;
|
||||
}, [clearance, contract]);
|
||||
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||
const reviewReadOnly = shipmentLocked;
|
||||
|
||||
@@ -185,6 +185,16 @@ export const contractsService = {
|
||||
requiredRole,
|
||||
}),
|
||||
|
||||
rejectStep: ({
|
||||
id,
|
||||
stepId,
|
||||
reason,
|
||||
}: {
|
||||
id: string;
|
||||
stepId: string;
|
||||
reason: string;
|
||||
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
|
||||
|
||||
// ── Contract document ──
|
||||
generateContract: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
|
||||
|
||||
Reference in New Issue
Block a user