mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1382 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Button, Group, Modal, Skeleton, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Clock,
|
||||
CreditCard,
|
||||
@@ -67,6 +67,8 @@ const CUSTOMER_CANCELLABLE_STATUSES = [
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"SELECTED_FOR_BATCH",
|
||||
// Parked waiting for a consolidation partner — nothing reserved yet.
|
||||
"PENDING_CONSOLIDATION",
|
||||
];
|
||||
|
||||
const cancelErrorMessage = (error: unknown) => {
|
||||
@@ -136,6 +138,32 @@ export function ReadonlyBookingView({
|
||||
const canCancel =
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
|
||||
// PAID booking (allocated or not): the same button cancels the WHOLE booking
|
||||
// through wagon cancellation — a per-wagon fee is invoiced and the paid
|
||||
// freight becomes a rebooking credit. Blocked once loading starts (server
|
||||
// enforces; loading flips status past PAID/TRUCK_ASSIGNED).
|
||||
const canCancelPaid =
|
||||
booking.paymentStatus === "PAID" &&
|
||||
["PAID", "TRUCK_ASSIGNED"].includes(status) &&
|
||||
Boolean(booking.contractId);
|
||||
const [paidCancelOpen, setPaidCancelOpen] = useState(false);
|
||||
const paidPreview = useQuery({
|
||||
queryKey: ["whole-cancel-preview", booking.id],
|
||||
queryFn: () => bookingsService.previewWagonCancellation(booking.id, {}),
|
||||
enabled: paidCancelOpen,
|
||||
});
|
||||
const paidCancelMutation = useMutation({
|
||||
mutationFn: () => bookingsService.requestWagonCancellation(booking.id, {}),
|
||||
onSuccess: () => {
|
||||
setPaidCancelOpen(false);
|
||||
toast.success(
|
||||
"Cancellation requested — pay the cancellation fee to settle it. Your paid freight is kept as credit for rebooking.",
|
||||
{ duration: 8000 },
|
||||
);
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) => toast.error(cancelErrorMessage(e)),
|
||||
});
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
|
||||
@@ -206,7 +234,8 @@ export function ReadonlyBookingView({
|
||||
actions={
|
||||
(canApproveDelivery ||
|
||||
(payables.items.length > 0 && tab !== "payments") ||
|
||||
canCancel) && (
|
||||
canCancel ||
|
||||
canCancelPaid) && (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{canApproveDelivery && (
|
||||
<ApproveDeliveryButton bookingId={booking.id} />
|
||||
@@ -227,6 +256,14 @@ export function ReadonlyBookingView({
|
||||
onClick={() => setCancelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{canCancelPaid && (
|
||||
<HeaderButton
|
||||
red
|
||||
icon={<XCircle size={16} />}
|
||||
label="Cancel booking"
|
||||
onClick={() => setPaidCancelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
@@ -414,6 +451,7 @@ export function ReadonlyBookingView({
|
||||
booking.paymentStatus === "PAID" &&
|
||||
Boolean(booking.contractId)
|
||||
}
|
||||
consolidated={Boolean(booking.consolidationPartnerId)}
|
||||
onCancellationRequested={onBookingUpdated}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
@@ -509,6 +547,86 @@ export function ReadonlyBookingView({
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={paidCancelOpen}
|
||||
onClose={() => setPaidCancelOpen(false)}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Cancel this booking?
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="#475569">
|
||||
You're about to cancel the whole booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
. A cancellation fee applies per wagon; your paid freight is kept as
|
||||
a credit you can rebook with once the fee is settled.
|
||||
{booking.consolidationPartnerId
|
||||
? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them."
|
||||
: ""}
|
||||
</Text>
|
||||
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
|
||||
{paidPreview.data && (
|
||||
<Stack
|
||||
gap={4}
|
||||
p={12}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FFFBEB",
|
||||
border: "1px solid #FDE68A",
|
||||
}}
|
||||
>
|
||||
<Text fz={13} c="#92400E">
|
||||
Wagons cancelled: <b>{paidPreview.data.wagons}</b>
|
||||
</Text>
|
||||
<Text fz={13} c="#92400E">
|
||||
Cancellation fee:{" "}
|
||||
<b>
|
||||
{Number(paidPreview.data.feeAmount).toLocaleString()}{" "}
|
||||
{paidPreview.data.feeCurrency}
|
||||
</b>{" "}
|
||||
({Number(paidPreview.data.feePerWagon).toLocaleString()} per
|
||||
wagon)
|
||||
</Text>
|
||||
<Text fz={13} c="#92400E">
|
||||
Rebooking credit:{" "}
|
||||
<b>
|
||||
{Number(paidPreview.data.creditAmount).toLocaleString()}{" "}
|
||||
{booking.paymentCurrency}
|
||||
</b>
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{paidPreview.isError && (
|
||||
<Text fz={13} c="#B3362C">
|
||||
{cancelErrorMessage(paidPreview.error)}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
onClick={() => setPaidCancelOpen(false)}
|
||||
>
|
||||
Keep booking
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius={10}
|
||||
disabled={!paidPreview.data}
|
||||
loading={paidCancelMutation.isPending}
|
||||
onClick={() => paidCancelMutation.mutate()}
|
||||
>
|
||||
Cancel booking & issue fee
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -303,11 +303,14 @@ function WagonCard({
|
||||
wagon,
|
||||
selectable,
|
||||
selected,
|
||||
shared,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: BookingWagonAllocation;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
/** Shared consolidation wagon — not selectable for cancellation. */
|
||||
shared?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const allocated = Number(wagon.allocatedWeightTons || 0);
|
||||
@@ -368,6 +371,14 @@ function WagonCard({
|
||||
<StatusPill status={wagon.status} />
|
||||
</Group>
|
||||
|
||||
{shared && (
|
||||
<Text fz={11.5} c="#B45309" mb={6}>
|
||||
Shared wagon — the other half belongs to another customer's
|
||||
booking, so it cannot be cancelled on its own. Cancel the whole
|
||||
booking to release it.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<LoadBar allocated={allocated} capacity={capacity} />
|
||||
|
||||
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
|
||||
@@ -464,6 +475,7 @@ export function WagonsTab({
|
||||
bookingId,
|
||||
currency,
|
||||
cancellable,
|
||||
consolidated,
|
||||
onCancellationRequested,
|
||||
}: {
|
||||
bookingId: string;
|
||||
@@ -471,6 +483,8 @@ export function WagonsTab({
|
||||
currency?: string;
|
||||
/** PAID contract booking — specific wagons may be selected for cancellation. */
|
||||
cancellable?: boolean;
|
||||
/** Consolidated booking — its shared wagon (a lone 20ft) cannot be cancelled alone. */
|
||||
consolidated?: boolean;
|
||||
onCancellationRequested?: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -711,19 +725,31 @@ export function WagonsTab({
|
||||
<CancelledWagonsSection rows={ownCancellations} />
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
|
||||
{wagons.map((w) => (
|
||||
<WagonCard
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
wagon={w}
|
||||
selectable={
|
||||
canSelect &&
|
||||
!!w.allocationId &&
|
||||
(w.status === "PLANNED" || w.status === "RESERVED")
|
||||
}
|
||||
selected={!!w.allocationId && selected.has(w.allocationId)}
|
||||
onToggle={() => w.allocationId && toggle(w.allocationId)}
|
||||
/>
|
||||
))}
|
||||
{wagons.map((w) => {
|
||||
// The shared consolidation wagon carries this booking's lone 20ft —
|
||||
// its other half belongs to the partner booking, so it can never be
|
||||
// cancelled on its own (the server rejects it too).
|
||||
const isSharedWagon =
|
||||
!!consolidated &&
|
||||
w.loadType === "CONTAINER" &&
|
||||
(w.containers ?? []).length === 1 &&
|
||||
Number(w.containers?.[0]?.sizeFt) === 20;
|
||||
return (
|
||||
<WagonCard
|
||||
key={w.allocationId ?? w.sequenceNo}
|
||||
wagon={w}
|
||||
shared={isSharedWagon}
|
||||
selectable={
|
||||
canSelect &&
|
||||
!isSharedWagon &&
|
||||
!!w.allocationId &&
|
||||
(w.status === "PLANNED" || w.status === "RESERVED")
|
||||
}
|
||||
selected={!!w.allocationId && selected.has(w.allocationId)}
|
||||
onToggle={() => w.allocationId && toggle(w.allocationId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -413,10 +412,10 @@ function NewShipmentBookingForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||
// the booking can never be planned. The server's shipment validation reports
|
||||
// it too, but only once the price modal opens — block it inline instead, the
|
||||
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
|
||||
// 20ft containers ride two per wagon. An odd total no longer blocks the
|
||||
// booking — the server auto-pairs it with another customer's odd booking, or
|
||||
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
|
||||
// gate the direct-booking flow already uses).
|
||||
const watchedContainers = form.watch("containers");
|
||||
const ft20Total =
|
||||
contract.freightType === "CONTAINER"
|
||||
@@ -581,8 +580,6 @@ function NewShipmentBookingForm({
|
||||
// run it for every freight type; container contracts additionally get
|
||||
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
||||
const handleReview = form.handleSubmit((values) => {
|
||||
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
|
||||
if (hasOdd20ft) return;
|
||||
setPendingValues(values);
|
||||
validateMutation.reset();
|
||||
validateMutation.mutate(buildDto(values));
|
||||
@@ -747,27 +744,27 @@ function NewShipmentBookingForm({
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||
withArrow
|
||||
disabled={!hasOdd20ft}
|
||||
{hasOdd20ft ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||
so the wrapper carries the hover target. */}
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1687,18 +1684,16 @@ function CargoStep({
|
||||
if (ft20 % 2 !== 1) return null;
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20})`}
|
||||
>
|
||||
<Text fz={13}>
|
||||
20ft containers travel two per wagon, so they must be booked
|
||||
in even numbers. Please add one more 20ft container or remove
|
||||
one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) —
|
||||
the booking cannot be submitted with an unpaired 20ft
|
||||
container.
|
||||
20ft containers travel two per wagon. This booking will be
|
||||
paired with another customer's odd booking to share a
|
||||
wagon, or held until one is available.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -106,15 +106,14 @@ export default function NewShipmentRequestPage() {
|
||||
contract.cargoScope?.[0];
|
||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||
// the request cannot be planned. Consolidation (pairing the odd container with
|
||||
// another customer's odd booking) is built but switched off for now, so an odd
|
||||
// request is blocked here rather than dead-ending downstream.
|
||||
// 20ft containers ride two per wagon. An odd total no longer blocks the
|
||||
// request — the server auto-pairs it with another customer's odd booking, or
|
||||
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
|
||||
// gate the direct-booking flow already uses).
|
||||
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
|
||||
const hasOdd20ft = ft20Requested % 2 === 1;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (hasOdd20ft) return;
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
@@ -221,17 +220,16 @@ export default function NewShipmentRequestPage() {
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20Requested})`}
|
||||
>
|
||||
<Text fz={13}>
|
||||
20ft containers travel two per wagon, so they must be requested
|
||||
in even numbers. Please add one more 20ft container or remove
|
||||
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
|
||||
instead of {ft20Requested}).
|
||||
20ft containers travel two per wagon. This request will be
|
||||
paired with another customer's odd booking to share a
|
||||
wagon, or held until one is available.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -282,7 +280,6 @@ export default function NewShipmentRequestPage() {
|
||||
leftSection={<Send size={16} />}
|
||||
loading={submit.isPending}
|
||||
onClick={handleSubmit}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Submit shipment request
|
||||
</Button>
|
||||
|
||||
@@ -217,6 +217,8 @@ export interface BookingWagonContainer {
|
||||
sealNumber: string | null;
|
||||
positionOnWagon: number | null;
|
||||
grossWeightTons: string | null;
|
||||
/** Container size in feet (20/40) — identifies the shared consolidation wagon. */
|
||||
sizeFt: number | null;
|
||||
}
|
||||
|
||||
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */
|
||||
|
||||
Reference in New Issue
Block a user