Merge pull request #1382 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-22 02:17:34 +03:00
committed by GitHub
22 changed files with 2295 additions and 1690 deletions

View File

@@ -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&apos;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 &amp; issue fee
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -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&apos;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

View File

@@ -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&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
);

View File

@@ -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&apos;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>

View File

@@ -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. */