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 ( +
+
+
+ +
+
+ +
+
+ {updateMutation.isError && ( +
+ +
+

Failed to save changes

+

+ {updateMutation.error instanceof Error + ? updateMutation.error.message + : "An unexpected error occurred. Please try again."} +

+
+
+ )} + {step === 1 && } + {step === 2 && } + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} +
+
+ +
+
+ + {step < STEPS.length ? ( + + ) : ( + + )} +
+
+
+ ); +} 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}`); },