From 7db03ea3476542480fb91775975188ed2e1b82bc Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 20:16:48 +0000 Subject: [PATCH] customer cancel + revise edit flow --- .../src/modules/bookings/bookings.service.ts | 41 +++++- .../ChangesRequestedView.tsx | 125 ++++++++++++++---- .../BookingDetailPage/DraftBookingView.tsx | 32 +++-- .../BookingDetailPage/ReadonlyBookingView.tsx | 16 ++- .../components/PageHeader.tsx | 93 +------------ 5 files changed, 178 insertions(+), 129 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 12c56ac13..1d2300d9a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx index 3cb3feb9e..6ae046f1d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -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 ( + + + + {icon} + + + + {title} + + + {description} + + + + + + + + ); +} + /** * 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({ setCancelDialogOpen(true), - onSupport: () => navigate("/support"), - }} + actions={ + } + label="Cancel booking" + onClick={() => setCancelDialogOpen(true)} + /> + } /> @@ -101,6 +160,41 @@ export function ChangesRequestedView({ + + Fix your booking + + 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. + + + } + 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`) + } + /> + } + 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`) + } + /> + } + 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`) + } + /> + + + Your documents @@ -108,25 +202,8 @@ export function ChangesRequestedView({ 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. - - {flow.validationError && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 277bd1a24..65af9f45d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -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({ } - label="Continue editing" - onClick={() => navigate(`/bookings/${booking.id}/edit`)} - /> + + } + label="Continue editing" + onClick={() => navigate(`/bookings/${booking.id}/edit`)} + /> + } + label="Cancel" + onClick={() => setCancelDialogOpen(true)} + /> + } - menuActions={{ - onCancel: () => setCancelDialogOpen(true), - onSupport: () => navigate("/support"), - }} /> {canApproveDelivery && ( @@ -185,14 +186,17 @@ export function ReadonlyBookingView({ onClick={pay.open} /> )} + {canCancel && ( + } + label="Cancel booking" + onClick={() => setCancelOpen(true)} + /> + )} ) } - menuActions={{ - onRebook: canSelfRebook ? onRebook : undefined, - onSupport: () => navigate("/support"), - onCancel: canCancel ? () => setCancelOpen(true) : undefined, - }} /> {isNegative(status) ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx index 455b71a69..95cfeab11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -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 ( @@ -83,66 +62,6 @@ export function PageHeader({ {actions} - {hasMenu && ( - - - - - - - - {menuActions!.onViewContract && ( - } - onClick={menuActions!.onViewContract} - > - View contract - - )} - {menuActions!.onEdit && ( - } - onClick={menuActions!.onEdit} - > - Edit - - )} - {menuActions!.onSupport && ( - } - onClick={menuActions!.onSupport} - > - Contact customer support - - )} - {menuActions!.onRebook && ( - } - onClick={menuActions!.onRebook} - > - Rebook similar schedule - - )} - {menuActions!.onCancel && ( - <> - - } - onClick={menuActions!.onCancel} - > - Cancel booking - - - )} - - - )} ); @@ -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", }, }} >