mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
248 lines
6.4 KiB
TypeScript
248 lines
6.4 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Check, ShieldCheck } from "lucide-react";
|
|
import {
|
|
Stack,
|
|
Group,
|
|
Text,
|
|
Badge,
|
|
Button,
|
|
Box,
|
|
Modal,
|
|
} 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 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);
|
|
|
|
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, requiredRole: pendingStep.requiredRole },
|
|
{ onSuccess: () => closeApprove() },
|
|
);
|
|
};
|
|
|
|
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={nextPending?.id === step.id}
|
|
isPending={mutations.approveStep.isPending}
|
|
onApprove={() => openApprove(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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StepRow({
|
|
step,
|
|
isNext,
|
|
isPending,
|
|
onApprove,
|
|
}: {
|
|
step: Freight.IContractApprovalStep;
|
|
isNext: boolean;
|
|
isPending: boolean;
|
|
onApprove: () => 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>
|
|
)}
|
|
<Badge
|
|
variant="light"
|
|
color={statusColor}
|
|
size="sm"
|
|
radius="sm"
|
|
tt="uppercase"
|
|
>
|
|
{step.status}
|
|
</Badge>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
}
|