mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
customer cancel + revise edit flow
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user