mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
218 lines
6.1 KiB
TypeScript
218 lines
6.1 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Check, ShieldCheck } from "lucide-react";
|
|
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
|
|
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
|
import {
|
|
buildApproveActionForStep,
|
|
canActOnApprovalStep,
|
|
getNextPendingApprovalStep,
|
|
} from "@/features/bookings/booking-actions.config";
|
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
|
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
|
import { SectionCard } from "./detail/SectionCard";
|
|
|
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
|
|
|
interface ApprovalStepsCardProps {
|
|
booking: BookingDetail;
|
|
mutations: Mutations;
|
|
}
|
|
|
|
/** Approval chain with inline approve on the current pending step. */
|
|
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
|
const { user } = useAuth();
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
|
|
|
const steps = useMemo(
|
|
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
|
[booking.approvalSteps],
|
|
);
|
|
|
|
const nextPending = getNextPendingApprovalStep(steps);
|
|
const summary = formatApprovalProgress(booking.status, steps);
|
|
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
|
|
|
const openApprove = (step: BookingApprovalStep) => {
|
|
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}
|
|
steps={steps}
|
|
user={user}
|
|
isNext={nextPending?.id === step.id}
|
|
isPending={mutations.approveStep.isPending}
|
|
onApprove={openApprove}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<BookingConfirmDialog
|
|
open={confirmOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) closeApprove();
|
|
else setConfirmOpen(true);
|
|
}}
|
|
action={pendingAction}
|
|
reference={booking.reference}
|
|
inputValue=""
|
|
onInputChange={() => {}}
|
|
onConfirm={runApprove}
|
|
isPending={mutations.approveStep.isPending}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function StepRow({
|
|
step,
|
|
steps,
|
|
user,
|
|
isNext,
|
|
isPending,
|
|
onApprove,
|
|
}: {
|
|
step: BookingApprovalStep;
|
|
steps: BookingApprovalStep[];
|
|
user: ReturnType<typeof useAuth>["user"];
|
|
isNext: boolean;
|
|
isPending: boolean;
|
|
onApprove: (step: BookingApprovalStep) => void;
|
|
}) {
|
|
const canApprove = canActOnApprovalStep(user, step, steps);
|
|
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.remarks && (
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{step.remarks}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
|
{canApprove && (
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
leftSection={<Check size={14} />}
|
|
disabled={isPending}
|
|
onClick={() => onApprove(step)}
|
|
>
|
|
Approve
|
|
</Button>
|
|
)}
|
|
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
|
{step.status}
|
|
</Badge>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
}
|