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

220 lines
6.5 KiB
TypeScript

import { useNavigate } from "react-router-dom";
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking";
interface BookingActionsMenuProps {
row: BookingListRow;
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void;
onAllocateBooking?: () => void;
}
export function BookingActionsMenu({
row,
variant = "table",
onSuppressRowClick,
onAllocateBooking,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const goToClearanceTab = () =>
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else if (isClearanceNavAction(action.id)) {
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else {
flow.openAction(action);
}
};
const hasMenu = listRowHasActions(row, user);
if (!hasMenu && variant === "table") {
return (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking"
>
<ChevronRight size={16} />
</ActionIcon>
);
}
// Toolbar: lay every action out as a button row.
if (variant === "toolbar" && actions.length > 0) {
return (
<>
<Group gap="sm" w="100%">
{actions.map((action) => {
const Icon = action.icon;
const destructive = action.variant === "destructive";
return (
<Button
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "edr-green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
>
{action.label}
</Button>
);
})}
</Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
</>
);
}
return (
<Group
gap={4}
justify="flex-end"
wrap="nowrap"
data-stop-row-click
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon
variant={variant === "table" ? "subtle" : "default"}
color="gray"
loading={mutations.isPending}
aria-label="Booking actions"
>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
<Text size="xs" ff="monospace" c="dimmed">
{row.reference}
</Text>
</Menu.Label>
{actions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.id}
color={action.variant === "destructive" ? "red" : undefined}
leftSection={<Icon size={15} />}
onClick={() => handleAction(action)}
>
{action.label}
</Menu.Item>
);
})}
{actions.length > 0 && <Menu.Divider />}
<Menu.Item
leftSection={<ExternalLink size={15} />}
onClick={() => {
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
Open full details
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
</Group>
);
}
function ActionDialog({
flow,
pendingAction,
onSuppressRowClick,
}: {
flow: ReturnType<typeof useBookingActionDialog>;
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onSuppressRowClick?: () => void;
}) {
return (
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={(open) => {
if (!open) onSuppressRowClick?.();
flow.setDialogOpen(open);
}}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
selectedFile={flow.selectedFile}
onFileChange={flow.setSelectedFile}
onConfirm={() => {
onSuppressRowClick?.();
flow.runAction();
}}
isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<Text size="sm" c="dimmed">
Loading approval steps
</Text>
) : pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<Text
size="sm"
c="orange.9"
p="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-orange-2)",
background: "var(--mantine-color-orange-0)",
}}
>
No pending approval step. Refresh the page after staff accept, or reject the
booking.
</Text>
) : null
}
/>
);
}