customer cancel + revise edit flow

This commit is contained in:
Marshal
2026-08-06 20:16:48 +00:00
parent 9a50df2be3
commit 7db03ea347
5 changed files with 178 additions and 129 deletions

View File

@@ -1425,7 +1425,46 @@ export class BookingsService {
tradeDirection,
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
// booking) must obey the same gate as creation: the route needs an OPEN
// departure on that EAT day that can carry the cargo. Skipped when the day
// didn't change, for general contracts (period-based, no pinned day) and
// for intercity (staff assign a passing train later).
if (dto.scheduledDate) {
const day = eatDay(new Date(dto.scheduledDate));
const dayChanged =
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
if (
dayChanged &&
existing.bookingType !== 'GENERAL_CONTRACT' &&
tradeDirection !== 'DOMESTIC'
) {
const { hasDeparture, hasCompatible } =
await this.trainSchedulingService.checkDayCargoCompatibility(
originYardId,
destinationYardId,
day,
{
freightType: freightType as 'CONTAINER' | 'BULK',
cargoTypeId,
containerTypeIds: containers
.map((c) => c.containerTypeId)
.filter((cid): cid is string => Boolean(cid)),
},
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
if (!hasCompatible) {
throw new BadRequestException(
'No wagon on the selected day can carry this cargo type — please choose another day',
);
}
}
updates.scheduledDate = new Date(dto.scheduledDate);
}
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);

View File

@@ -1,18 +1,30 @@
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Pencil, Send, XCircle } from "lucide-react";
import {
AlertCircle,
CalendarDays,
ChevronRight,
Package,
Pencil,
Send,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal";
@@ -25,13 +37,55 @@ import { CompanyInfoCard } from "./components/CompanyInfoCard";
import { ContainersCard } from "./components/ContainersCard";
import { ContractInfoCard } from "./components/ContractInfoCard";
import { ActionRequiredBanner, MutationErrors } from "./components/Notices";
import { PageHeader } from "./components/PageHeader";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { EstimateCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
/** Row linking straight to one section of the edit-booking form. */
function EditLink({
icon,
title,
description,
onClick,
}: {
icon: ReactNode;
title: string;
description: string;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="sm"
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
width: "100%",
}}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Box mt={2} c="#0A6F4D">
{icon}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz={13.5} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" mt={2}>
{description}
</Text>
</Box>
<Box mt={2} c="#9AA8B5">
<ChevronRight size={16} />
</Box>
</Group>
</UnstyledButton>
);
}
/**
* Detail-page view for a booking staff returned with CHANGES_REQUESTED.
*
@@ -64,8 +118,9 @@ export function ChangesRequestedView({
null) as Freight.PricingBreakdown | null;
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -76,10 +131,14 @@ export function ChangesRequestedView({
<PageShell>
<PageHeader
booking={booking}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
actions={
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelDialogOpen(true)}
/>
}
/>
<MutationErrors mutations={[...flow.mutations, cancelMutation]} />
@@ -101,6 +160,41 @@ export function ChangesRequestedView({
<ContractInfoCard booking={booking} />
<SectionCard>
<CardTitle>Fix your booking</CardTitle>
<Text fz="12.5px" c="#6B7C8E" mt={4} mb="md">
Staff asked for changes on this booking. Update whatever needs
fixing below, then resubmit for review the booking stays in
place, no need to start over.
</Text>
<Stack gap={8}>
<EditLink
icon={<Package size={16} />}
title="Cargo & containers"
description="Add or remove containers, change container type, quantity or VGM — or for bulk cargo, change the commodity and tonnage."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=cargo`)
}
/>
<EditLink
icon={<CalendarDays size={16} />}
title="Schedule date"
description="Pick a different departure day — only days with an open schedule on your route can be selected."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=schedule`)
}
/>
<EditLink
icon={<Pencil size={16} />}
title="Route, service & other details"
description="Change the origin or destination yard, service type, trucking options or notes."
onClick={() =>
navigate(`/bookings/${booking.id}/edit?section=service`)
}
/>
</Stack>
</SectionCard>
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Your documents</CardTitle>
@@ -108,25 +202,8 @@ export function ChangesRequestedView({
<Text fz="12.5px" c="#6B7C8E" mb="sm">
Update the documents for this booking, then resubmit for review.
Replace any that changed and attach any that are still required.
Need to change the cargo itself containers, route, schedule or
other details? Edit the booking first, then come back and
resubmit.
</Text>
<Button
fullWidth
radius={10}
variant="default"
leftSection={<Pencil size={16} />}
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 700 },
}}
>
Edit booking details
</Button>
<ResubmitDocuments flow={flow} />
{flow.validationError && (

View File

@@ -24,7 +24,10 @@ import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { downloadStoredFile } from "@/services/files.service";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import {
bookingsService,
type SubmitBookingResponse,
} from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -116,8 +119,9 @@ export function DraftBookingView({
});
const cancelMutation = useMutation({
// Customer-facing cancel endpoint — the plain /cancel route is staff-only.
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
bookingsService.customerCancel(booking.id, reason),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
@@ -157,17 +161,21 @@ export function DraftBookingView({
<PageHeader
booking={booking}
actions={
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<Group gap={8} wrap="nowrap">
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel"
onClick={() => setCancelDialogOpen(true)}
/>
</Group>
}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors

View File

@@ -7,6 +7,7 @@ import {
LayoutGrid,
Package,
Truck,
XCircle,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -172,7 +173,7 @@ export function ReadonlyBookingView({
<PageHeader
booking={booking}
actions={
(canApproveDelivery || (canPay && !showCountdown)) && (
(canApproveDelivery || (canPay && !showCountdown) || canCancel) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -185,14 +186,17 @@ export function ReadonlyBookingView({
onClick={pay.open}
/>
)}
{canCancel && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setCancelOpen(true)}
/>
)}
</Group>
)
}
menuActions={{
onRebook: canSelfRebook ? onRebook : undefined,
onSupport: () => navigate("/support"),
onCancel: canCancel ? () => setCancelOpen(true) : undefined,
}}
/>
{isNegative(status) ? (

View File

@@ -1,14 +1,5 @@
import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core";
import {
ArrowDownLeft,
ArrowUpRight,
Edit2,
FileText,
HelpCircle,
MoreHorizontal,
RefreshCw,
XCircle,
} from "lucide-react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -20,22 +11,12 @@ import {
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
onViewContract?: () => void;
onCancel?: () => void;
onEdit?: () => void;
onSupport?: () => void;
onRebook?: () => void;
}
export function PageHeader({
booking,
actions,
menuActions,
}: {
booking: Freight.IBooking;
actions?: ReactNode;
menuActions?: PageHeaderMenuActions;
}) {
const status = booking.status as string;
const negative = isNegative(status);
@@ -47,8 +28,6 @@ export function PageHeader({
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const isExport = booking.tradeDirection === "EXPORT";
const hasMenu = menuActions && Object.values(menuActions).some(Boolean);
return (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
@@ -83,66 +62,6 @@ export function PageHeader({
<Group gap={8} wrap="nowrap" align="center">
{actions}
{hasMenu && (
<Menu shadow="md" radius={12} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="default"
size={42}
radius={10}
aria-label="More options"
>
<MoreHorizontal size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={210}>
{menuActions!.onViewContract && (
<Menu.Item
leftSection={<FileText size={15} />}
onClick={menuActions!.onViewContract}
>
View contract
</Menu.Item>
)}
{menuActions!.onEdit && (
<Menu.Item
leftSection={<Edit2 size={15} />}
onClick={menuActions!.onEdit}
>
Edit
</Menu.Item>
)}
{menuActions!.onSupport && (
<Menu.Item
leftSection={<HelpCircle size={15} />}
onClick={menuActions!.onSupport}
>
Contact customer support
</Menu.Item>
)}
{menuActions!.onRebook && (
<Menu.Item
leftSection={<RefreshCw size={15} />}
onClick={menuActions!.onRebook}
>
Rebook similar schedule
</Menu.Item>
)}
{menuActions!.onCancel && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<XCircle size={15} />}
onClick={menuActions!.onCancel}
>
Cancel booking
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
);
@@ -154,6 +73,7 @@ export function HeaderButton({
onClick,
dark,
green,
red,
disabled,
}: {
label: string;
@@ -161,6 +81,7 @@ export function HeaderButton({
onClick?: () => void;
dark?: boolean;
green?: boolean;
red?: boolean;
disabled?: boolean;
}) {
return (
@@ -169,14 +90,14 @@ export function HeaderButton({
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
variant={green || dark ? "filled" : red ? "outline" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : red ? "red" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
color: green || dark ? "#fff" : red ? undefined : "#10202F",
},
}}
>