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 543817f92..fa1900734 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -1,59 +1,99 @@ -import { useMemo } from "react"; -import { useFieldArray, Controller, useForm } from "react-hook-form"; +import { useMemo, useRef, type ReactNode } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate, useParams } from "react-router-dom"; +import { + ActionIcon, + Alert, + Box, + Button, + Center, + Divider, + Group, + Loader, + Paper, + SimpleGrid, + Stack, + Switch, + Text, + Textarea, + TextInput, + Title, +} from "@mantine/core"; import { AlertCircle, + AlertTriangle, Check, - LoaderCircle, - Loader2, - Package, - Weight, - Plus, - Trash2, - MapPin, + Download, + FileText, Flame, + MapPin, Snowflake, Truck, - FileText, + Upload, + X, } from "lucide-react"; -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 { BookingFormInputValues, + BOOKING_DOCS_SETTING, bookingFormSchema, getRouteDirection, initialBookingFormValues, + type BookingDocuments, type BookingFormValues, type RouteDirection, } from "./new-booking-form/schema"; -import { SelectField, AlertBox } from "./new-booking-form/shared"; +import { SelectField } from "./new-booking-form/shared"; +import { Step5CargoDetails } from "./new-booking-form/steps"; +import { + CountChip, + DocRow, + IconSquare, +} from "./BookingDetailPage/components/Documents"; -function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { +function yardNameFromBooking( + yard: { label?: string; code?: string; name?: string } | undefined | null, +): string { return yard?.label ?? yard?.name ?? yard?.code ?? ""; } +/** Fallback container type for a size, used only when a booking row has no + * loaded containerType relation. */ +function defaultContainerTypeForSize( + referenceData: Freight.BookingReferenceData, + size: string, +): string { + const norm = (s: string) => s.toLowerCase().replace(/\s|ft/g, ""); + const group = + referenceData.containers.find((g) => norm(g.size) === norm(size)) ?? + referenceData.containers[0]; + return group?.types[0]?.name ?? ""; +} + +/** The API returns the `bookingContainers` relation (with `containerType` + * loaded), not the `{ type, qty, vgm }` shape the frontend type advertises. */ +interface BookingContainerRow { + quantity: number; + vgmPerUnitTons: number | string; + containerType?: { + sizeFt?: number | null; + label?: string | null; + code?: string | null; + } | null; +} + function mapBookingToFormValues( booking: Freight.IBooking, referenceData: Freight.BookingReferenceData, ): BookingFormInputValues { const vals: BookingFormInputValues = { ...initialBookingFormValues, - contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", + contractType: + (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", previousContractRef: booking.previousContractId ?? "", serviceType: booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail", @@ -66,7 +106,9 @@ function mapBookingToFormValues( deliveryAddress: booking.lastMileDeliveryAddress ?? "", }, equipmentReturn: - booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return", + booking.equipmentReturn === "WITH_RETURN" + ? "with_return" + : "without_return", originYard: yardNameFromBooking(booking.originYard), destinationYard: yardNameFromBooking(booking.destinationYard), cargoType: booking.freightType === "BULK" ? "bulk" : "container", @@ -76,7 +118,8 @@ function mapBookingToFormValues( shippingLine: (booking as any).shippingLine?.name ?? "", consolidationEnabled: booking.allowConsolidation ?? false, notes: "", - termsAccepted: false, + // Terms were accepted at creation; editing shouldn't re-gate on them. + termsAccepted: true, freightType: "", bulkCommoditytype: "", containers: [], @@ -94,22 +137,114 @@ function mapBookingToFormValues( } } - 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), - })); + if (booking.freightType === "CONTAINER") { + const rows = ((booking as any).bookingContainers ?? []) as BookingContainerRow[]; + vals.containers = + rows.length > 0 + ? rows.map((bc) => { + const size = + bc.containerType?.sizeFt === 40 + ? ("40ft" as const) + : ("20ft" as const); + const typeName = bc.containerType?.label?.trim() + ? bc.containerType.label + : (bc.containerType?.code ?? + defaultContainerTypeForSize(referenceData, size)); + return { + type: size, + containerType: typeName, + qty: String(bc.quantity ?? 1), + vgm: String(Number(bc.vgmPerUnitTons ?? 0)), + }; + }) + : [ + { + type: "20ft" as const, + containerType: defaultContainerTypeForSize(referenceData, "20ft"), + qty: "1", + vgm: "", + }, + ]; } return vals; } +function SectionHeading({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + {title} + + + {description} + + + ); +} + +function ToggleRow({ + icon, + title, + description, + checked, + onChange, + children, +}: { + icon: ReactNode; + title: string; + description: string; + checked: boolean; + onChange: (value: boolean) => void; + children?: ReactNode; +}) { + return ( + + + + {icon} + + + {title} + + + {description} + + + + onChange(e.currentTarget.checked)} + color="edr-green" + /> + + {children} + + ); +} + +const DIRECTION_COLOR: Record = { + export: "blue", + import: "yellow", + domestic: "gray", +}; +const DIRECTION_LABEL: Record = { + export: "Export workflow (inside country to outside country)", + import: "Import workflow (outside country to inside country)", + domestic: "Domestic corridor", +}; + export default function EditBookingPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const docInputRefs = useRef>({}); const bookingQuery = useQuery( api.bookings.get.queryOptions({ @@ -124,16 +259,6 @@ export default function EditBookingPage() { }), ); - 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 => { @@ -148,25 +273,42 @@ export default function EditBookingPage() { mode: "onChange", }); + const updateMutation = useMutation({ + mutationFn: async (payload: Partial) => { + const result = await api.bookings.update.call({ id: id!, dto: payload }); + + // Upload any newly attached documents against the existing booking. + const documents = (form.getValues("documents") ?? {}) as BookingDocuments; + const hasDocuments = Object.values(documents).some((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value), + ); + if (hasDocuments) { + await api.bookings.uploadDocuments.call({ id: id!, files: documents }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); + navigate(`/bookings/${id}`); + }, + }); + 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 documents = (form.watch("documents") ?? {}) as BookingDocuments; const direction: RouteDirection = useMemo( () => getRouteDirection(originYard, destinationYard), [originYard, destinationYard], ); - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "containers", - }); - const yardOptions = useMemo(() => { if (!referenceData?.yard) return []; return referenceData.yard.map((y) => ({ @@ -184,37 +326,13 @@ export default function EditBookingPage() { })); }, [referenceData]); - const freightTypeGroups = useMemo(() => { - if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.filter( - (g) => g.code !== "CONTAINER", + const setDocument = (key: string, file: File | null) => { + const current = (form.getValues("documents") ?? {}) as BookingDocuments; + form.setValue( + "documents", + { ...current, [key]: file }, + { shouldDirty: true }, ); - }, [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) => { @@ -238,14 +356,12 @@ export default function EditBookingPage() { const selectedChild = data.cargoType !== "container" && data.bulkCommoditytype ? cargoTree - .find((g) => g.code.toLowerCase() === data.freightType) - ?.children?.find((c) => c.name === data.bulkCommoditytype) + .find((g) => g.code.toLowerCase() === data.freightType) + ?.children?.find((c) => c.name === data.bulkCommoditytype) : undefined; const cargoTypeId = - data.cargoType === "container" - ? undefined - : selectedChild?.id ?? ""; + data.cargoType === "container" ? undefined : (selectedChild?.id ?? ""); const findContainerTypeId = (name: string): string => { for (const group of containerGroups) { @@ -258,9 +374,9 @@ export default function EditBookingPage() { const totalWeight = data.cargoType === "container" ? data.containers.reduce( - (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), - 0, - ) + (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ) : Number(data.cargoWeight || 0); const apiPayload: Partial = { @@ -293,10 +409,10 @@ export default function EditBookingPage() { containers: data.cargoType === "container" ? data.containers.map((c) => ({ - containerTypeId: findContainerTypeId(c.containerType), - quantity: Number(c.qty || 1), - vgmPerUnitTons: Number(c.vgm || 0), - })) + containerTypeId: findContainerTypeId(c.containerType), + quantity: Number(c.qty || 1), + vgmPerUnitTons: Number(c.vgm || 0), + })) : [], ...(data.previousContractRef ? { previousContractId: data.previousContractRef } @@ -320,743 +436,488 @@ export default function EditBookingPage() { if (bookingQuery.isLoading) { return ( -
- -
+
+ + + + Loading booking… + + +
); } if (bookingQuery.isError || !booking) { return ( -
-
- -

Failed to load booking

- -
-
+ + ); } if (!formValues) { return ( -
- -
+
+ +
); } + const uploadedCodes = new Set( + booking.files?.map((f) => f.code).filter(Boolean) ?? [], + ); + const uploadedCount = BOOKING_DOCS_SETTING.fields.filter((f) => + uploadedCodes.has(f.fileKey), + ).length; + return ( -
-
-

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

-

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

+ + 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 && ( + } + radius="md" + mt="lg" + title="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. -

-
-
- ( - - )} - /> - - -
-
- - - - {/* ── Section 2: Service ── */} -
-
-

Service

-

- Select the service combination and configure trucking options. -

-
- -
- ( - - )} - /> - - ( - - )} - /> -
- - {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. -

-
- -
- ( - y.value !== destinationYard)} - /> - )} - /> - - ( - y.value !== originYard)} - /> - )} - /> -
- - {direction && ( -
- - {directionLabel[direction]} -
- )} - - {direction && direction !== "domestic" && ( - ( - - )} - /> - )} - -
-
-
- -
-

Hazardous Material

-

- Applies a Hazard Surcharge to the final bill. -

-
-
- ( - - )} - /> -
-
-
- -
-

Refrigerated Cargo

-

- Temperature-controlled transport applies a Refrigerator Surcharge. -

-
-
- ( - - )} - /> -
-
-
- - - - {/* ── Section 4: Cargo ── */} -
-
-

Cargo Details

-

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

-
- -
- ( - - )} - /> - - ( - - - Total Cargo Weight (Tons) * - -
- - -
- -
- )} - /> -
- - {cargoType === "bulk" && ( - <> -
- ( - ({ - value: g.code.toLowerCase(), - label: g.name, - }))} - /> - )} - /> - - {freightType && commodityOptions.length > 0 && ( - ( - - )} - /> - )} -
- - ( -
-
-

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 && ( - - )} -
- -
- ( - - )} - /> - - ( - - )} - /> - - ( - - 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. -

-
+ + {/* ── Section 1: Service ── */} + + + ( - - Additional Notes -