mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Check, ShieldCheck } from "lucide-react";
|
|
|
|
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 { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
|
import { Badge, Button } from "@edr/ui-common";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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() },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
|
<div className={bookingSurface.sectionHeader}>
|
|
<div className={bookingSurface.sectionIcon}>
|
|
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-foreground">
|
|
Approval chain
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
{summary.detail ||
|
|
(nextPending
|
|
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
|
: steps.length
|
|
? "All steps complete"
|
|
: "Accept submission to begin")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-5 py-5">
|
|
{steps.length === 0 ? (
|
|
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
|
|
Use{" "}
|
|
<strong className="font-semibold text-foreground">
|
|
Accept for approval
|
|
</strong>{" "}
|
|
in staff actions to instantiate steps.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{steps.map((step) => (
|
|
<StepRow
|
|
key={step.id}
|
|
step={step}
|
|
steps={steps}
|
|
user={user}
|
|
isNext={nextPending?.id === step.id}
|
|
isPending={mutations.approveStep.isPending}
|
|
onApprove={openApprove}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<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 statusStyles =
|
|
step.status === "APPROVED"
|
|
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
|
: step.status === "REJECTED"
|
|
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
|
: isNext
|
|
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
|
: "bg-muted/40 text-muted-foreground";
|
|
|
|
return (
|
|
<li
|
|
className={cn(
|
|
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
|
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span
|
|
className={cn(
|
|
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
|
isNext
|
|
? cn(bookingGlass.iconWellGreen, "text-black")
|
|
: "bg-muted/40 text-muted-foreground",
|
|
)}
|
|
>
|
|
{step.stepOrder}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{step.requiredRole}
|
|
</p>
|
|
{step.remarks && (
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{step.remarks}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{canApprove && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-8 gap-1.5 shadow-sm"
|
|
disabled={isPending}
|
|
onClick={() => onApprove(step)}
|
|
>
|
|
<Check className="size-3.5" />
|
|
Approve
|
|
</Button>
|
|
)}
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
|
>
|
|
{step.status}
|
|
</Badge>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|