mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import { useCallback, useState } from "react";
|
||
|
||
import {
|
||
getBookingActions,
|
||
type BookingActionContext,
|
||
type BookingActionDef,
|
||
} from "@/features/bookings/booking-actions.config";
|
||
import { useAuth } from "@/auth/useAuth";
|
||
import { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||
|
||
/** A contract validity window must be a whole number of days, 1–365. */
|
||
function isValidValidityDays(value: string): boolean {
|
||
const days = Number(value.trim());
|
||
return Number.isInteger(days) && days >= 1 && days <= 365;
|
||
}
|
||
|
||
/**
|
||
* Decisions that must be applied to BOTH halves of a consolidated pair. The two
|
||
* bookings share one wagon: accepting one alone would put half a wagon into the
|
||
* approval chain, and cancelling one alone would strand the other on a wagon it
|
||
* can no longer fill.
|
||
*/
|
||
const PAIRED_DECISIONS = {
|
||
accept: "accept",
|
||
cancel: "cancel",
|
||
operationAccept: "operationAccept",
|
||
requestChanges: "requestChanges",
|
||
} as const;
|
||
|
||
export function useBookingActionDialog(
|
||
bookingId: string,
|
||
context: BookingActionContext,
|
||
) {
|
||
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
|
||
const [inputValue, setInputValue] = useState("");
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
|
||
// Bookings no longer have an approval chain, so the dialog needs nothing
|
||
// beyond the list-row context it was handed.
|
||
const detailLoading = false;
|
||
|
||
const mergedContext: BookingActionContext = { ...context };
|
||
|
||
const { user } = useAuth();
|
||
const mutations = useBookingMutations(bookingId);
|
||
const actions = getBookingActions(mergedContext, user);
|
||
|
||
const openAction = useCallback((action: BookingActionDef) => {
|
||
setPendingAction(action);
|
||
setInputValue("");
|
||
setSelectedFile(null);
|
||
setDialogOpen(true);
|
||
}, []);
|
||
|
||
const closeDialog = useCallback(() => {
|
||
setDialogOpen(false);
|
||
setPendingAction(null);
|
||
setInputValue("");
|
||
setSelectedFile(null);
|
||
}, []);
|
||
|
||
const runAction = useCallback(() => {
|
||
if (!pendingAction) return;
|
||
|
||
const onSuccess = () => closeDialog();
|
||
|
||
// A booking on a shared wagon routes the four pairable decisions through the
|
||
// paired endpoint, which applies them to both halves all-or-nothing. Every
|
||
// other action stays per booking.
|
||
const pairedDecision =
|
||
PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
|
||
if (context.consolidationPartnerId && pairedDecision) {
|
||
if (pairedDecision === "accept") {
|
||
const days = Number(inputValue.trim());
|
||
if (!Number.isInteger(days) || days < 1 || days > 365) return;
|
||
mutations.pairedDecision.mutate(
|
||
{ decision: "accept", validityDays: days },
|
||
{ onSuccess },
|
||
);
|
||
return;
|
||
}
|
||
mutations.pairedDecision.mutate(
|
||
pairedDecision === "cancel"
|
||
? { decision: "cancel", reason: inputValue.trim() }
|
||
: { decision: pairedDecision, note: inputValue.trim() },
|
||
{ onSuccess },
|
||
);
|
||
return;
|
||
}
|
||
|
||
switch (pendingAction.id) {
|
||
case "accept": {
|
||
const days = Number(inputValue.trim());
|
||
if (!Number.isInteger(days) || days < 1 || days > 365) return;
|
||
mutations.staffAccept.mutate(days, { onSuccess });
|
||
break;
|
||
}
|
||
case "requestChanges":
|
||
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
|
||
break;
|
||
case "reject":
|
||
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
|
||
break;
|
||
case "operationAccept":
|
||
mutations.reviewOperation.mutate({ decision: "ACCEPT" }, { onSuccess });
|
||
break;
|
||
case "operationRequestChanges":
|
||
mutations.reviewOperation.mutate(
|
||
{ decision: "REQUEST_CHANGES", note: inputValue.trim() },
|
||
{ onSuccess },
|
||
);
|
||
break;
|
||
case "viewContract":
|
||
break;
|
||
case "startTransit":
|
||
mutations.startTransit.mutate(undefined, { onSuccess });
|
||
break;
|
||
case "complete":
|
||
mutations.complete.mutate(undefined, { onSuccess });
|
||
break;
|
||
case "cancel":
|
||
mutations.cancel.mutate(inputValue.trim(), { onSuccess });
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}, [
|
||
pendingAction,
|
||
inputValue,
|
||
selectedFile,
|
||
mutations,
|
||
closeDialog,
|
||
]);
|
||
|
||
const confirmDisabled =
|
||
mutations.isPending ||
|
||
(pendingAction?.input === "file" && !selectedFile) ||
|
||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
|
||
|
||
return {
|
||
actions,
|
||
pendingAction,
|
||
inputValue,
|
||
setInputValue,
|
||
selectedFile,
|
||
setSelectedFile,
|
||
dialogOpen,
|
||
setDialogOpen: (open: boolean) => {
|
||
if (!open) closeDialog();
|
||
else setDialogOpen(true);
|
||
},
|
||
openAction,
|
||
closeDialog,
|
||
runAction,
|
||
mutations,
|
||
confirmDisabled,
|
||
detailLoading,
|
||
mergedContext,
|
||
};
|
||
}
|