mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
412 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|