Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx

412 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
Text,
Badge,
Button,
Box,
Modal,
Select,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractApprovalStepsCardProps {
contract: Freight.IContract;
mutations: Mutations;
}
/** Approval chain with inline approve on the next pending step. */
export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
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("");
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
// earlier APPROVED step to send the chain back to. First approver has no
// choice — customer only.
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
const steps = useMemo(
() =>
[...(contract.approvalSteps ?? [])].sort(
(a, b) => a.stepOrder - b.stepOrder,
),
[contract.approvalSteps],
);
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// The card also renders read-only trails (e.g. a REJECTED contract) — only
// offer approve/reject while the backend accepts step actions.
const actionable =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
const openApprove = (step: Freight.IContractApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id },
{ onSuccess: () => closeApprove() },
);
};
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectTarget("CUSTOMER");
setRejectOpen(true);
};
const closeReject = () => {
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
setRejectTarget("CUSTOMER");
};
const trimmedReason = rejectReason.trim();
// Earlier stages this rejection can be returned to — only stages that have
// already approved. Empty for the first approver, whose only target is the
// customer.
const returnableSteps = rejectStepRow
? steps.filter(
(s) =>
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
)
: [];
const sendBack = rejectTarget !== "CUSTOMER";
const targetStep = sendBack
? returnableSteps.find((s) => s.id === rejectTarget)
: undefined;
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{
stepId: rejectStepRow.id,
reason: trimmedReason,
returnToStepId: sendBack ? rejectTarget : undefined,
},
{ onSuccess: () => closeReject() },
);
};
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
return (
<>
<SectionCard
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
>
<Text size="xs" c="dimmed" mb="sm">
{subtitle}
</Text>
{steps.length === 0 ? (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
px="md"
style={{
borderRadius: 8,
border: "1px dashed var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
}}
>
Use <strong>Accept for approval</strong> in staff actions to
instantiate steps.
</Text>
) : (
<Stack gap="xs">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={actionable && nextPending?.id === step.id}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
}
onApprove={() => openApprove(step)}
onReject={() => openReject(step)}
/>
))}
</Stack>
)}
</SectionCard>
<Modal
opened={confirmOpen}
onClose={closeApprove}
title="Approve this step?"
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
You are about to approve the{" "}
<Text span fw={600} c="dark">
{pendingStep?.requiredRole}
</Text>{" "}
step for contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>
. This action cannot be undone from this screen.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeApprove}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
loading={mutations.approveStep.isPending}
onClick={runApprove}
>
Confirm approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}
onClose={closeReject}
title="Reject this step?"
radius="md"
centered
>
<Stack gap="md">
{returnableSteps.length > 0 && (
<Select
label="Send rejection to"
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
allowDeselect={false}
value={rejectTarget}
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
data={[
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
})),
]}
/>
)}
{sendBack ? (
<Text size="sm" c="dimmed">
Contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
notified.
</Text>
) : (
<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 resubmit this cannot be undone.
</Text>
)}
<Textarea
label="Reason for rejection"
description={
sendBack
? "Shared with the approval chain (not the customer)."
: "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={sendBack ? "orange" : "red"}
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
: "Reject contract"}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
function StepRow({
step,
isNext,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
}) {
const statusColor =
step.status === "APPROVED"
? "edr-green"
: step.status === "REJECTED"
? "red"
: isNext
? "edr-green"
: "gray";
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
background: "var(--mantine-color-gray-1)",
color: isNext
? "var(--mantine-color-gray-7)"
: "var(--mantine-color-gray-6)",
}}
>
{step.stepOrder}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{step.requiredRole}
</Text>
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}
</Text>
)}
</Box>
</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"
variant="light"
color="red"
leftSection={<X size={14} />}
disabled={isPending}
onClick={onReject}
>
Reject
</Button>
</>
)}
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
</Group>
</Group>
);
}