From af6804eb91d3b5b5d409ecadd7a8f258756b14d1 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Sat, 6 Jun 2026 11:13:46 +0300
Subject: [PATCH] feat(bookings): implement booking editing functionality
---
apps/edr-freight-web/portal/src/App.tsx | 2 +
.../src/pages/bookings/BookingDetailPage.tsx | 38 +-
.../src/pages/bookings/EditBookingPage.tsx | 374 ++++++++++++++++++
.../src/pages/bookings/NewBookingPage.tsx | 5 +-
.../portal/src/services/api.ts | 5 +
.../portal/src/services/bookings.service.ts | 8 +
6 files changed, 414 insertions(+), 18 deletions(-)
create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 147668d91..6c979eaa0 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -32,6 +32,7 @@ import MyBookings from "./pages/bookings/MyBookings";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
+import EditBookingPage from "./pages/bookings/EditBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import { useEffect } from "react";
@@ -106,6 +107,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
index fd69b68f0..d4fcfae13 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
@@ -403,20 +403,30 @@ function DraftBookingView({
-
+
+
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
new file mode 100644
index 000000000..68fd9c70f
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx
@@ -0,0 +1,374 @@
+import { useMemo, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { useNavigate, useParams } from "react-router-dom";
+import {
+ AlertCircle,
+ Check,
+ ChevronLeft,
+ ChevronRight,
+ LoaderCircle,
+ Loader2,
+} from "lucide-react";
+import { Button } from "@edr/ui-common";
+import { api } from "@/services/api";
+import type {
+ CreateBookingPayload,
+} from "@/services/bookings.service";
+import {
+ BookingFormInputValues,
+ STEPS,
+ bookingFormSchema,
+ getRouteDirection,
+ initialBookingFormValues,
+ stepFields,
+ type BookingFormValues,
+ type RouteDirection,
+} from "./new-booking-form/schema";
+import { StepIndicator } from "./new-booking-form/StepIndicator";
+import {
+ Step1ContractType,
+ Step2ServiceType,
+ Step4Route,
+ Step5CargoDetails,
+ Step8Review,
+} from "./new-booking-form/steps";
+import type { Freight } from "@edr/types";
+
+function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string {
+ return yard?.label ?? yard?.name ?? yard?.code ?? "";
+}
+
+function mapBookingToFormValues(
+ booking: Freight.IBooking,
+ referenceData: Freight.BookingReferenceData,
+): BookingFormInputValues {
+ const vals: BookingFormInputValues = {
+ ...initialBookingFormValues,
+ contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
+ previousContractRef: booking.previousContractId ?? "",
+ serviceType:
+ booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail",
+ firstMile: {
+ enabled: booking.firstMileEnabled ?? false,
+ pickUpAddress: booking.firstMilePickupAddress ?? "",
+ },
+ lastMile: {
+ enabled: booking.lastMileEnabled ?? false,
+ deliveryAddress: booking.lastMileDeliveryAddress ?? "",
+ },
+ equipmentReturn:
+ booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return",
+ originYard: yardNameFromBooking(booking.originYard),
+ destinationYard: yardNameFromBooking(booking.destinationYard),
+ cargoType: booking.freightType === "BULK" ? "bulk" : "container",
+ cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
+ isHazardous: booking.isHazardous ?? false,
+ isRefrigerated: booking.isRefrigerated ?? false,
+ shippingLine: (booking as any).shippingLine?.name ?? "",
+ consolidationEnabled: booking.allowConsolidation ?? false,
+ notes: "",
+ termsAccepted: false,
+ freightType: "",
+ bulkCommoditytype: "",
+ containers: [],
+ };
+
+ const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
+ if (booking.freightType === "BULK" && bookingCargoTypeId) {
+ for (const group of referenceData.cargo_type) {
+ const child = group.children?.find((c) => c.id === bookingCargoTypeId);
+ if (child) {
+ vals.freightType = group.code.toLowerCase();
+ vals.bulkCommoditytype = child.name;
+ break;
+ }
+ }
+ }
+
+ if (booking.freightType === "CONTAINER" && booking.containers && booking.containers.length > 0) {
+ vals.containers = booking.containers.map((c) => ({
+ type: c.type === "40ft" ? "40ft" : "20ft" as const,
+ containerType: "",
+ qty: String(c.qty),
+ vgm: String(c.vgm),
+ }));
+ }
+
+ return vals;
+}
+
+export default function EditBookingPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [step, setStep] = useState(1);
+
+ const bookingQuery = useQuery(
+ api.bookings.get.queryOptions({
+ input: { id: id! },
+ enabled: !!id,
+ }),
+ );
+
+ const { data: referenceData, isLoading: refDataLoading } = useQuery(
+ api.bookings.referenceData.queryOptions({
+ enabled: !!bookingQuery.data,
+ }),
+ );
+
+ const updateMutation = useMutation({
+ mutationFn: (payload: Partial) =>
+ api.bookings.update.call({ id: id!, dto: payload }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
+ queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) });
+ navigate(`/bookings/${id}`);
+ },
+ });
+
+ const booking = bookingQuery.data;
+
+ const formValues = useMemo((): BookingFormInputValues | undefined => {
+ if (!booking || !referenceData) return undefined;
+ return mapBookingToFormValues(booking, referenceData);
+ }, [booking, referenceData]);
+
+ const form = useForm({
+ defaultValues: initialBookingFormValues,
+ values: formValues,
+ resolver: zodResolver(bookingFormSchema),
+ mode: "onChange",
+ });
+
+ const originYard = form.watch("originYard");
+ const destinationYard = form.watch("destinationYard");
+
+ const direction: RouteDirection = useMemo(
+ () => getRouteDirection(originYard, destinationYard),
+ [originYard, destinationYard],
+ );
+
+ async function handleContinue() {
+ const valid = await form.trigger(stepFields[step], { shouldFocus: true });
+ if (!valid) return;
+ setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
+ }
+
+ const handleSubmit = form.handleSubmit((data) => {
+ const yards = referenceData?.yard ?? [];
+ const services = referenceData?.service ?? [];
+ const shippingLines = referenceData?.shipping_line ?? [];
+ const cargoTree = referenceData?.cargo_type ?? [];
+ const containerGroups = referenceData?.containers ?? [];
+
+ const findYardId = (name: string): string =>
+ yards.find((y) => y.name === name)?.id ?? "";
+
+ const findServiceTypeId = (): string => {
+ const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
+ return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
+ };
+
+ const findShippingLineId = (name: string): string | undefined =>
+ shippingLines.find((l) => l.name === name)?.id;
+
+ const selectedChild =
+ data.cargoType !== "container" && data.bulkCommoditytype
+ ? cargoTree
+ .find((g) => g.code.toLowerCase() === data.freightType)
+ ?.children?.find((c) => c.name === data.bulkCommoditytype)
+ : undefined;
+
+ const cargoTypeId =
+ data.cargoType === "container"
+ ? undefined
+ : selectedChild?.id ?? "";
+
+ const findContainerTypeId = (name: string): string => {
+ for (const group of containerGroups) {
+ const ct = group.types.find((t) => t.name === name);
+ if (ct) return ct.id;
+ }
+ return "";
+ };
+
+ const totalWeight =
+ data.cargoType === "container"
+ ? data.containers.reduce(
+ (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
+ 0,
+ )
+ : Number(data.cargoWeight || 0);
+
+ const apiPayload: Partial = {
+ scheduledDate: new Date().toISOString().slice(0, 10),
+ contractType:
+ data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
+ serviceTypeId: findServiceTypeId(),
+ equipmentReturn:
+ data.equipmentReturn === "with_return"
+ ? "WITH_RETURN"
+ : "WITHOUT_RETURN",
+ originYardId: findYardId(data.originYard),
+ destinationYardId: findYardId(data.destinationYard),
+ tradeDirection:
+ direction === "export"
+ ? "EXPORT"
+ : direction === "domestic"
+ ? "DOMESTIC"
+ : "IMPORT",
+ cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
+ cargoTotalWeightVgm: totalWeight,
+ isHazardous: data.isHazardous,
+ paymentCurrency: "USD",
+ allowConsolidation: data.consolidationEnabled,
+ // @ts-ignore
+ freightType:
+ data.cargoType === "container"
+ ? ("CONTAINER" as const)
+ : ("BULK" as const),
+ containers:
+ data.cargoType === "container"
+ ? data.containers.map((c) => ({
+ containerTypeId: findContainerTypeId(c.containerType),
+ quantity: Number(c.qty || 1),
+ vgmPerUnitTons: Number(c.vgm || 0),
+ }))
+ : [],
+ ...(data.previousContractRef
+ ? { previousContractId: data.previousContractRef }
+ : {}),
+ ...(data.contractType === "renewal" && data.previousContractRef
+ ? { pnrCode: data.previousContractRef }
+ : {}),
+ ...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
+ ? { firstMilePickupAddress: data.firstMile.pickUpAddress }
+ : {}),
+ ...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
+ ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
+ : {}),
+ ...(data.shippingLine
+ ? { shippingLineId: findShippingLineId(data.shippingLine) }
+ : {}),
+ };
+
+ updateMutation.mutate(apiPayload);
+ });
+
+ if (bookingQuery.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (bookingQuery.isError || !booking) {
+ return (
+
+
+
+
Failed to load booking
+
+
+
+ );
+ }
+
+ if (!formValues) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index 9b5a4cf73..78ba91af6 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -138,10 +138,7 @@ export default function NewBookingPage() {
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
- : (findCargoTypeId(data.bulkCommoditytype) ??
- cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
- ?.id ??
- "");
+ : selectedChild?.id ?? "";
const cargoFreeText =
data.cargoType === "container"
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts
index 9b306b8b1..c49ebad0f 100644
--- a/apps/edr-freight-web/portal/src/services/api.ts
+++ b/apps/edr-freight-web/portal/src/services/api.ts
@@ -134,6 +134,11 @@ export const api = {
bookingsService.create,
),
+ update: endpoint<
+ { id: string; dto: Partial },
+ { booking: Freight.IBooking; warnings: string[] }
+ >("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
+
referenceData: endpoint(
"bookings",
"referenceData",
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 95a3e5740..d9481d59e 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -74,6 +74,14 @@ export const bookingsService = {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
+ update: async (
+ id: string,
+ payload: Partial,
+ ): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
+ const { data } = await client.patch(`/api/bookings/${id}`, payload);
+ return data.data;
+ },
+
remove: async (id: string): Promise => {
await client.delete(`/api/bookings/${id}`);
},