Files
edr-platform/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
Marshal 85c2fd1428 feat: enhance contract clearance process with linked booking details
- Added  and  to  for better visibility of GL-created shipment bookings.
- Implemented  method in  to fetch the latest clearance phase for contracts, improving list responses.
- Introduced  property in the  entity to store the latest clearance cycle's phase.
- Updated  to surface linked booking information in the clearance view.
- Created  component to display detailed container information in booking details.
- Refactored booking actions to remove contract-related actions from the booking request page.
- Enhanced the  component to reflect the current phase of clearance actions.
- Updated UI components to provide clearer messaging regarding the status of clearance and linked bookings.
- Adjusted action handling in  to include duty payment actions.
- Improved the  to show hints for each phase of the clearance process.
2026-07-03 21:04:46 +00:00

413 lines
12 KiB
TypeScript

import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
MessageSquareWarning,
Play,
ShieldCheck,
XCircle,
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import {
FREIGHT_PERMS,
hasPermission,
isFreightApprovalAdmin,
} from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
BookingStatus,
} from "@/types/booking";
export type BookingActionId =
| "accept"
| "requestChanges"
| "reject"
| "approve"
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "reviewClearance"
| "allocateBooking"
| "startTransit"
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "cancel";
export type BookingActionInputKind =
| "note"
| "reason"
| "file"
| "days"
| "amount";
export interface BookingActionDef {
id: BookingActionId;
label: string;
shortLabel: string;
description: string;
confirmTitle: string;
confirmDescription: string;
variant: "default" | "destructive" | "outline";
icon: LucideIcon;
input?: BookingActionInputKind;
inputLabel?: string;
inputPlaceholder?: string;
primary?: boolean;
}
export type BookingActionContext = Pick<
BookingDetail,
| "status"
| "paymentCurrency"
| "approvalSteps"
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([
"NOT_SCHEDULED",
"HOLDING",
"ELIGIBLE",
undefined,
null,
"",
]);
export function canAllocateBooking(
booking: Pick<BookingDetail, "status" | "schedulingStatus">,
) {
return (
booking.status === "PAID" &&
ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined)
);
}
export function getNextPendingApprovalStep(
steps?: BookingApprovalStep[] | null,
): BookingApprovalStep | undefined {
if (!steps?.length) return undefined;
return [...steps]
.sort((a, b) => a.stepOrder - b.stepOrder)
.find((s) => s.status === "PENDING");
}
function approvalActions(
steps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
const next = getNextPendingApprovalStep(steps);
if (!next) return [];
return [
buildApproveActionForStep(next),
{
id: "rejectApproval",
label: "Reject approval",
shortLabel: "Reject",
description: "Reject at the current approval step",
confirmTitle: "Reject at approval step?",
confirmDescription:
"The booking will be marked rejected. This action cannot be undone from the UI.",
variant: "destructive",
icon: XCircle,
input: "reason",
inputLabel: "Rejection reason",
inputPlaceholder: "Explain why this booking is rejected…",
},
];
}
const SUBMITTED_ACTIONS: BookingActionDef[] = [
{
id: "accept",
label: "Accept for approval",
shortLabel: "Accept",
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",
label: "Request changes",
shortLabel: "Changes",
description: "Ask the customer to update and resubmit",
confirmTitle: "Request changes from customer?",
confirmDescription:
"The customer will see your note and can edit the booking before resubmitting.",
variant: "outline",
icon: MessageSquareWarning,
input: "note",
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to be corrected or added…",
},
{
id: "reject",
label: "Reject booking",
shortLabel: "Reject",
description: "Reject this submission",
confirmTitle: "Reject booking?",
confirmDescription:
"The booking will be marked rejected and removed from active queues.",
variant: "destructive",
icon: Ban,
input: "reason",
inputLabel: "Rejection reason",
inputPlaceholder: "Reason for rejection…",
},
];
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
id: "operationAccept",
label: "Accept operation",
shortLabel: "Accept",
description: "Accept the operation request and release it for dispatch",
confirmTitle: "Accept operation request?",
confirmDescription:
"Train orders enter the batch pool; road orders move to truck dispatch.",
variant: "default",
icon: Check,
primary: true,
},
{
id: "operationRequestChanges",
label: "Request changes",
shortLabel: "Changes",
description: "Ask the customer to adjust the operation request",
confirmTitle: "Request changes to the operation?",
confirmDescription:
"The customer will see your note and can adjust and resubmit the order.",
variant: "outline",
icon: MessageSquareWarning,
input: "note",
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
];
const CANCEL_ACTION: BookingActionDef = {
id: "cancel",
label: "Cancel booking",
shortLabel: "Cancel",
description: "Cancel this booking",
confirmTitle: "Cancel booking?",
confirmDescription:
"The booking will be marked cancelled. Provide a reason for the audit trail.",
variant: "destructive",
icon: Ban,
input: "reason",
inputLabel: "Cancellation reason",
inputPlaceholder: "Reason for cancellation…",
};
// Opens the booking detail straight on the Clearance tab so Marketing can
// review the customer's clearance documents (non-customs bookings only).
const REVIEW_CLEARANCE_ACTION: BookingActionDef = {
id: "reviewClearance",
label: "Review clearance",
shortLabel: "Clearance",
description: "Approve or query the customer's clearance documents",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: ShieldCheck,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION];
}
const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
accept: FREIGHT_PERMS.bookings.staffAccept,
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
reject: FREIGHT_PERMS.bookings.reject,
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};
const approvePermissionForRole = (role: string): string | undefined => {
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
return undefined;
};
/** True when this step is the current pending step and the user may approve it. */
export function canActOnApprovalStep(
user: AuthUser | null | undefined,
step: BookingApprovalStep,
steps?: BookingApprovalStep[] | null,
): boolean {
if (step.status !== "PENDING") return false;
const next = getNextPendingApprovalStep(steps);
if (!next || next.id !== step.id) return false;
if (isFreightApprovalAdmin(user)) return true;
const perm = approvePermissionForRole(step.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
export function buildApproveActionForStep(
step: BookingApprovalStep,
): BookingActionDef {
return {
id: "approve",
label: `Approve (${step.requiredRole})`,
shortLabel: "Approve",
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
confirmTitle: `Approve as ${step.requiredRole}?`,
confirmDescription:
"This records your approval and advances the booking to the next step in the chain.",
variant: "default",
icon: Check,
primary: true,
};
}
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
approvalSteps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
if (!user) return [];
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
return canActOnApprovalStep(user, next, approvalSteps);
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;
});
}
/** Actions available for the current booking status (detail or list). */
export function getBookingActions(
ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
let actions: BookingActionDef[];
switch (status) {
case "SUBMITTED":
actions = withCancel(SUBMITTED_ACTIONS);
break;
case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE":
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [CANCEL_ACTION];
break;
case "CONTRACT_READY":
case "SIGNED_CUSTOMER":
case "FULLY_EXECUTED":
// Contract view/sign/executed buttons intentionally removed from the
// booking-request page.
actions = [];
break;
case "AWAITING_DOCUMENTS":
case "DOCUMENTS_UNDER_REVIEW":
// Marketing reviews non-customs clearance here; customs bookings are
// handled in the Global Logistics clearance queue, not the booking list.
actions = ctx.customsClearingEnabled
? [CANCEL_ACTION]
: withCancel([REVIEW_CLEARANCE_ACTION]);
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "PAID":
// Allocate is handled by the Operations "Ready to allocate" queue, not the
// per-booking action menu. Start transit was removed entirely. No per-row
// action remains in the PAID state.
actions = [];
break;
case "IN_TRANSIT":
actions = [
{
id: "complete",
label: "Complete booking",
shortLabel: "Complete",
description: "Mark journey finished",
confirmTitle: "Complete booking?",
confirmDescription:
"Marks the booking as completed. No further staff transitions apply.",
variant: "default",
icon: Play,
primary: true,
},
];
break;
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
case "PENDING_CONSOLIDATION":
// View-only while waiting for a consolidation partner; cancel still allowed.
actions = [CANCEL_ACTION];
break;
default:
actions = [];
}
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
}
/** Opens contract page without confirmation dialog. */
export function isContractNavAction(id: BookingActionId): boolean {
return id === "viewContract" || id === "signContractStaff";
}
/** Opens allocation wizard without confirmation dialog. */
export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";
}
export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
},
user?: AuthUser | null,
): boolean {
const actions = getBookingActions(
{
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
},
user,
);
return actions.length > 0;
}