diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index 68fd9c70f..b72d18ee1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -1,40 +1,51 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; +import { useFieldArray, Controller, useForm } from "react-hook-form"; 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, + Package, + Weight, + Plus, + Trash2, + MapPin, + Flame, + Snowflake, + Truck, + FileText, } from "lucide-react"; -import { Button } from "@edr/ui-common"; +import { + Button, + Field, + FieldLabel, + FieldError, + Input, + Badge, + Switch, + Textarea, + Separator, + Skeleton, +} from "@edr/ui-common"; +import type { Freight } from "@edr/types"; import { api } from "@/services/api"; -import type { - CreateBookingPayload, -} from "@/services/bookings.service"; +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"; + SelectField, + SelectItem, + AlertBox, +} from "./new-booking-form/shared"; function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { return yard?.label ?? yard?.name ?? yard?.code ?? ""; @@ -103,7 +114,6 @@ 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({ @@ -112,7 +122,7 @@ export default function EditBookingPage() { }), ); - const { data: referenceData, isLoading: refDataLoading } = useQuery( + const { data: referenceData } = useQuery( api.bookings.referenceData.queryOptions({ enabled: !!bookingQuery.data, }), @@ -144,17 +154,72 @@ export default function EditBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const serviceType = form.watch("serviceType"); + const firstMileEnabled = form.watch("firstMile.enabled"); + const lastMileEnabled = form.watch("lastMile.enabled"); + const cargoType = form.watch("cargoType"); + const freightType = form.watch("freightType"); + const containers = form.watch("containers"); 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 { fields, append, remove } = useFieldArray({ + control: form.control, + name: "containers", + }); + + const yardOptions = useMemo(() => { + if (!referenceData?.yard) return []; + return referenceData.yard.map((y) => ({ + value: y.name, + label: y.name, + country: y.country, + })); + }, [referenceData]); + + const shippingLineOptions = useMemo(() => { + if (!referenceData?.shipping_line) return []; + return referenceData.shipping_line.map((sl) => ({ + value: sl.name, + label: sl.name, + })); + }, [referenceData]); + + const freightTypeGroups = useMemo(() => { + if (!referenceData?.cargo_type) return []; + return referenceData.cargo_type.filter( + (g) => g.code !== "CONTAINER", + ); + }, [referenceData]); + + const commodityOptions = useMemo(() => { + if (!referenceData?.cargo_type || !freightType) return []; + const group = referenceData.cargo_type.find( + (g) => g.code.toLowerCase() === freightType, + ); + return group?.children?.map((c) => c.name) ?? []; + }, [referenceData, freightType]); + + const containerTypeOptions = useMemo(() => { + if (!referenceData?.containers) return []; + return referenceData.containers.flatMap((group) => + group.types.map((t) => t.name), + ); + }, [referenceData]); + + const directionStyle: Record = { + export: "bg-sky-50 text-sky-800 border-sky-200", + import: "bg-amber-50 text-amber-800 border-amber-200", + domestic: "bg-muted text-muted-foreground border-border", + }; + const directionLabel: Record = { + export: "Export workflow (inside country to outside country)", + import: "Import workflow (outside country to inside country)", + domestic: "Domestic corridor", + }; const handleSubmit = form.handleSubmit((data) => { const yards = referenceData?.yard ?? []; @@ -290,83 +355,744 @@ export default function EditBookingPage() { return (
-
-
- -
-
+
+

+ Edit Booking {booking.reference ?? ""} +

+

+ Update the booking details below. All changes are saved together. +

-
-
- {updateMutation.isError && ( -
- -
-

Failed to save changes

-

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

+ {updateMutation.isError && ( +
+ +
+

Failed to save changes

+

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

+
+
+ )} + +
+ {/* ── Section 1: Contract ── */} +
+
+

Contract

+

+ New contract or renewal of an existing one. +

+
+
+ ( + + New Contract + Contract Renewal + + )} + /> + + +
+
+ + + + {/* ── Section 2: Service ── */} +
+
+

Service

+

+ Select the service combination and configure trucking options. +

+
+ +
+ ( + + Rail Transport Only + Logistics (Rail + Forwarding) + + )} + /> + + ( + + With Return + Without Return + + )} + /> +
+ + {serviceType === "rail_forwarding" && ( +
+
+ ( +
+
+ +
+

First Mile - Pick-up

+

+ Truck pick-up from your premises to the origin rail yard. +

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("firstMile.pickUpAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {firstMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+ ( +
+
+ +
+

Last Mile - Delivery

+

+ Truck delivery from the destination rail yard to the final address. +

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("lastMile.deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {lastMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+ ( +
+
+ +
+

Customs Clearing Service

+

+ EDR handles customs documentation and clearance on your behalf. +

+
+
+ +
+ )} + /> +
+
+ )} +
+ + + + {/* ── Section 3: Route ── */} +
+
+

Route

+

+ Select the origin and destination yards. +

+
+ +
+ ( + + {yardOptions.length === 0 ? ( + No yards available + ) : ( + yardOptions + .filter((y) => y.value !== destinationYard) + .map((y) => ( + + {y.label} + + )) + )} + + )} + /> + + ( + + {yardOptions.length === 0 ? ( + No yards available + ) : ( + yardOptions + .filter((y) => y.value !== originYard) + .map((y) => ( + + {y.label} + + )) + )} + + )} + /> +
+ + {direction && ( +
+ + {directionLabel[direction]} +
+ )} + + {direction && direction !== "domestic" && ( + ( + + {shippingLineOptions.map((sl) => ( + + {sl.label} + + ))} + + )} + /> + )} + +
+
+
+ +
+

Hazardous Material

+

+ Applies a Hazard Surcharge to the final bill. +

+
+
+ ( + + )} + /> +
+
+
+ +
+

Refrigerated Cargo

+

+ Temperature-controlled transport applies a Refrigerator Surcharge. +

+
+
+ ( + + )} + />
- )} - {step === 1 && } - {step === 2 && } - {step === 3 && ( - - )} - {step === 4 && ( - - )} - {step === 5 && ( - - )} -
-
+ -
-
+ + + {/* ── Section 4: Cargo ── */} +
+
+

Cargo Details

+

+ Define your cargo type, weight, and container configuration. +

+
+ +
+ ( + + Containerized + General Cargo + + )} + /> + + ( + + + Total Cargo Weight (Tons) * + +
+ + +
+ +
+ )} + /> +
+ + {cargoType === "bulk" && ( + <> +
+ ( + + {freightTypeGroups.map((group) => ( + + {group.name} + + ))} + + )} + /> + + {freightType && commodityOptions.length > 0 && ( + ( + + {commodityOptions.map((option) => ( + + {option} + + ))} + + )} + /> + )} +
+ + ( +
+
+

Allow Consolidation

+

+ Combine shipments to optimize costs. +

+
+ +
+ )} + /> + + )} + + {cargoType === "container" && ( +
+
+

+ Containers +

+ +
+ + {fields.map((field, index) => { + const containerType = containers[index]?.type; + const vgm = containers[index]?.vgm ?? 0; + const alert = (() => { + if (containerType === "20ft" && +vgm > 0) { + const limit = direction === "export" ? 25 : 20; + if (+vgm > limit) { + return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; + } + } + if (containerType === "40ft" && +vgm > 32.5) { + return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`; + } + return null; + })(); + + return ( +
+
+

+ Container {index + 1} +

+ {fields.length > 1 && ( + + )} +
+ +
+ ( + + 20ft (TEU) + 40ft (FEU) + + )} + /> + + ( + + {containerTypeOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + ( + + Quantity * + qtyField.onChange(e.target.value)} + onBlur={qtyField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + min="1" + /> + + + )} + /> + + ( + + VGM (Tons) * + vgmField.onChange(e.target.value)} + onBlur={vgmField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + placeholder="e.g. 18.5" + min="0" + step="0.1" + /> + + + )} + /> +
+ + {alert && ( + + Overweight Alert: {alert} + + )} +
+ ); + })} + + {containers && (() => { + const Ft40Wagons = containers + .filter((c) => c.type === "40ft") + .reduce((sum, c) => sum + Number(c.qty), 0); + const Ft20Wagons = containers + .filter((c) => c.type === "20ft") + .reduce((sum, c) => sum + Number(c.qty), 0); + const hasOddUnit = Ft20Wagons % 2 === 1; + if (hasOddUnit) { + return ( + +
+
+

Unpaired 20ft Container

+

+ One 20ft container occupies only half a wagon. The wagon + will depart once a co-loader is found to fill the + remaining slot, which{" "} + may delay departure beyond the standard + lead time. +

+
+
+
+ ); + } + return null; + })()} +
+ )} +
+ + + + {/* ── Section 5: Notes & Submit ── */} +
+
+

Notes & Confirmation

+

+ Add any special instructions and confirm the changes. +

+
+ + ( + + Additional Notes +