Files
edr-platform/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
2026-06-04 15:16:27 +03:00

234 lines
7.5 KiB
TypeScript

import { useNavigate } from "react-router-dom";
import {
ChevronRight,
ExternalLink,
Loader2,
MoreHorizontal,
Upload,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import {
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@edr/ui-common";
interface BookingActionsMenuProps {
row: BookingListRow;
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
}
export function BookingActionsMenu({
row,
variant = "table",
className,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
};
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const showUsdPaymentHint =
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") {
return (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-primary"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking"
>
<ChevronRight className="size-4" />
</Button>
);
}
return (
<>
<div
className={cn(
"flex items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="sm"
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
? goToContract()
: flow.openAction(primary)
}
>
<primary.icon className="size-3.5" />
{primary.shortLabel}
</Button>
)}
{variant === "toolbar" && actions.length > 0 ? (
<div className="flex w-full flex-wrap gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={
action.variant === "destructive"
? "outline"
: action.primary
? "default"
: "outline"
}
className={cn(
"gap-2 shadow-sm",
action.variant === "destructive" &&
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4" />
{action.label}
</Button>
);
})}
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant === "table" ? "ghost" : "outline"}
size={variant === "table" ? "icon" : "sm"}
className={cn(
variant === "table" ? "size-8" : "gap-2",
"shrink-0",
)}
disabled={mutations.isPending}
aria-label="Booking actions"
>
{mutations.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
{row.reference}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{actions.map((action) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={action.id}
className={cn(
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4 opacity-70" />
<span>{action.label}</span>
</DropdownMenuItem>
);
})}
{showUsdPaymentHint && (
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<Upload className="size-4 opacity-70" />
Upload payment proof
</DropdownMenuItem>
)}
{(actions.length > 0 || showUsdPaymentHint) && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
No pending approval step found. Accept the submission on the detail
page first.
</p>
) : null
}
/>
</>
);
}