mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
|
|
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
|
import { OperationRescheduleModal } from "./OperationRescheduleModal";
|
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
|
import {
|
|
isAllocateAction,
|
|
isClearanceNavAction,
|
|
isContractNavAction,
|
|
isRescheduleAction,
|
|
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,
|
|
schedulingStatus: row.schedulingStatus,
|
|
customsClearingEnabled: row.customsClearingEnabled,
|
|
consolidationPartnerId: row.consolidationPartnerId,
|
|
};
|
|
|
|
const flow = useBookingActionDialog(row.id, context);
|
|
const { actions, pendingAction, mutations } = flow;
|
|
// Day / train reschedule has its own modal (date + export train picker).
|
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
|
|
|
const goToContract = () =>
|
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
|
|
|
const goToClearanceTab = () =>
|
|
navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`);
|
|
|
|
const goToAdditionalCharges = () =>
|
|
navigate(`/dashboard/booking-requests/${row.id}?tab=additional-charges`);
|
|
|
|
const canSeeAdditionalCharges = hasFreightPermission(
|
|
user,
|
|
FREIGHT_PERMS.additionalCharges.view,
|
|
);
|
|
|
|
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 if (isRescheduleAction(action.id)) {
|
|
setRescheduleOpen(true);
|
|
} else {
|
|
flow.openAction(action);
|
|
}
|
|
};
|
|
|
|
const hasMenu = listRowHasActions(row, user);
|
|
|
|
// Row click already opens the detail page — no chevron affordance needed.
|
|
if (!hasMenu && variant === "table") {
|
|
return null;
|
|
}
|
|
|
|
// 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}
|
|
consolidationPartnerId={row.consolidationPartnerId}
|
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
|
/>
|
|
<OperationRescheduleModal
|
|
bookingId={row.id}
|
|
opened={rescheduleOpen}
|
|
onClose={() => {
|
|
onSuppressRowClick?.();
|
|
setRescheduleOpen(false);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 />}
|
|
{canSeeAdditionalCharges && (
|
|
<Menu.Item
|
|
leftSection={<Receipt size={15} />}
|
|
onClick={() => {
|
|
onSuppressRowClick?.();
|
|
goToAdditionalCharges();
|
|
}}
|
|
>
|
|
Additional charges
|
|
</Menu.Item>
|
|
)}
|
|
<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}
|
|
consolidationPartnerId={row.consolidationPartnerId}
|
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
|
/>
|
|
<OperationRescheduleModal
|
|
bookingId={row.id}
|
|
opened={rescheduleOpen}
|
|
onClose={() => {
|
|
onSuppressRowClick?.();
|
|
setRescheduleOpen(false);
|
|
}}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function ActionDialog({
|
|
flow,
|
|
pendingAction,
|
|
onSuppressRowClick,
|
|
consolidationPartnerId,
|
|
consolidationPartnerReference,
|
|
}: {
|
|
flow: ReturnType<typeof useBookingActionDialog>;
|
|
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
|
onSuppressRowClick?: () => void;
|
|
consolidationPartnerId?: string | null;
|
|
consolidationPartnerReference?: string | null;
|
|
}) {
|
|
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}
|
|
// Only the four pairable decisions land on both halves; the rest stay
|
|
// per booking, so the warning must not appear for them.
|
|
pairedWithReference={
|
|
consolidationPartnerId &&
|
|
pendingAction &&
|
|
["accept", "cancel", "operationAccept", "requestChanges"].includes(
|
|
pendingAction.id,
|
|
)
|
|
? (consolidationPartnerReference ?? "its wagon partner")
|
|
: null
|
|
}
|
|
/>
|
|
);
|
|
}
|