From 80db8f11df6a17eceb84012042ea6cd77b771725 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 22 May 2026 16:21:14 +0300 Subject: [PATCH 1/2] refactor --- .../src/pages/bookings/NewBookingPage.tsx | 9 +- .../pages/bookings/new-booking-form/schema.ts | 105 +- .../bookings/new-booking-form/shared.tsx | 18 +- .../new-booking-form/step1-contract-type.tsx | 140 ++ .../new-booking-form/step2-service-type.tsx | 72 + .../step3-first-last-mile.tsx | 148 ++ .../bookings/new-booking-form/step4-route.tsx | 124 ++ .../new-booking-form/step5-cargo-details.tsx | 430 ++++++ .../step6-wagon-allocation.tsx | 139 ++ .../new-booking-form/step7-documents.tsx | 65 + .../new-booking-form/step8-review.tsx | 285 ++++ .../pages/bookings/new-booking-form/steps.tsx | 1311 +---------------- 12 files changed, 1480 insertions(+), 1366 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step7-documents.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx 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 2fe104665..e23b44cb2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -42,8 +42,7 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); - const containerType = form.watch("containerType"); - const quantity = form.watch("quantity"); + const containers = form.watch("containers"); const previousContractRef = form.watch("previousContractRef"); const contractId = form.watch("draftContractId") || form.watch("previousContractRef"); @@ -54,9 +53,9 @@ export default function NewBookingPage() { ); const wagons = useMemo(() => { - if (!containerType || !quantity) return null; - return calcWagons(containerType, parseInt(quantity) || 1); - }, [containerType, quantity]); + if (!containers || containers.length === 0) return null; + return calcWagons(containers); + }, [containers]); useEffect(() => { setRenewalValid(null); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index a34f716c1..f1921738d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -121,23 +121,6 @@ export const BOOKING_DOCS_SETTING = { maxSizeMb: 5, order: 3, }, - { - id: "f4", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "national_id", - fileLabel: "National ID / Passport", - helpText: - "Valid government-issued ID or passport of the authorized signatory.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 4, - }, { id: "f5", createdAt: "", @@ -189,9 +172,13 @@ export const bookingFormSchema = z breakBulkTypeOther: z.string(), isHazardous: z.boolean(), isRefrigerated: z.boolean(), - containerType: z.enum(["20ft", "40ft", ""]), - quantity: z.string(), - vgm: z.string(), + containers: z.array( + z.object({ + type: z.enum(["20ft", "40ft"]), + qty: z.number(), + vgm: z.number(), + }), + ), consolidationEnabled: z.boolean(), documents: z.record(z.string(), fileValueSchema), notes: z.string(), @@ -342,31 +329,31 @@ export const bookingFormSchema = z } if (data.cargoType === "container") { - if (!data.containerType) { + if (data.containers.length === 0) { ctx.addIssue({ code: "custom", - path: ["containerType"], - message: "Select a container type.", + path: ["containers"], + message: "Add at least one container.", }); } - const quantity = Number(data.quantity); - if (!data.quantity || !Number.isInteger(quantity) || quantity < 1) { - ctx.addIssue({ - code: "custom", - path: ["quantity"], - message: "Enter at least 1 container.", - }); - } + data.containers.forEach((c, i) => { + if (!c.qty || c.qty < 1) { + ctx.addIssue({ + code: "custom", + path: ["containers", i, "qty"], + message: "Enter at least 1 container.", + }); + } - const vgm = Number(data.vgm); - if (!data.vgm || Number.isNaN(vgm) || vgm <= 0) { - ctx.addIssue({ - code: "custom", - path: ["vgm"], - message: "Enter VGM greater than 0.", - }); - } + if (!c.vgm || c.vgm <= 0) { + ctx.addIssue({ + code: "custom", + path: ["containers", i, "vgm"], + message: "Enter VGM greater than 0.", + }); + } + }); } for (const key of REQUIRED_DOC_KEYS) { @@ -413,9 +400,7 @@ export const initialBookingFormValues: BookingFormValues = { breakBulkTypeOther: "", isHazardous: false, isRefrigerated: false, - containerType: "", - quantity: "1", - vgm: "", + containers: [{ type: "20ft", qty: 1, vgm: 0 }], consolidationEnabled: false, documents: {}, notes: "", @@ -441,9 +426,7 @@ export const stepFields: Record> = { "bulkCommodityOther", "breakBulkType", "breakBulkTypeOther", - "containerType", - "quantity", - "vgm", + "containers", ], 6: ["consolidationEnabled"], 7: ["documents"], @@ -452,6 +435,12 @@ export const stepFields: Record> = { export type RouteDirection = "import" | "export" | "domestic" | null; +export interface ContainerConfig { + type: "20ft" | "40ft"; + qty: number; + vgm: number; +} + export interface WagonCalcResult { totalWagons: number; hasOddUnit: boolean; @@ -477,15 +466,23 @@ export function getRouteDirection( return null; } -export function calcWagons( - type: BookingFormValues["containerType"], - qty: number, -): WagonCalcResult { - if (type === "40ft") { - return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty }; +export function calcWagons(containers: ContainerConfig[]): WagonCalcResult { + let totalWagons = 0; + let hasOddUnit = false; + let sharedWagons = 0; + + for (const c of containers) { + if (c.type === "40ft") { + totalWagons += c.qty; + sharedWagons += c.qty; + } else { + const pairs = Math.floor(c.qty / 2); + const odd = c.qty % 2; + totalWagons += pairs + odd; + sharedWagons += pairs; + if (odd > 0) hasOddUnit = true; + } } - const pairs = Math.floor(qty / 2); - const odd = qty % 2; - return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs }; + return { totalWagons, hasOddUnit, sharedWagons }; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx index 298381ba0..f9fd5bdda 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx @@ -22,8 +22,22 @@ import { SelectValue, } from "@edr/ui-common"; import type { BookingFormValues } from "./schema"; +import { REQUIRED_DOC_KEYS } from "./schema"; import { cn } from "@/lib/utils"; +export function getUploadedRequiredCount( + documents: BookingFormValues["documents"], +) { + return REQUIRED_DOC_KEYS.filter((key) => { + const file = documents[key]; + return Array.isArray(file) ? file.length > 0 : Boolean(file); + }).length; +} + +export function OptionFieldError({ error }: { error?: { message?: string } }) { + return ; +} + export function OptionCard({ selected, onClick, @@ -151,7 +165,3 @@ export function SelectOptions({ options }: { options: readonly string[] }) { ); } - -export function FormFieldDescription({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx new file mode 100644 index 000000000..1343bdb2e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -0,0 +1,140 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { FileText, Loader2, RefreshCw } from "lucide-react"; +import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common"; +import { type BookingFormValues, genContractId } from "./schema"; +import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step1ContractType({ + form, + renewalValid, + renewalValidating, + onValidate, +}: { + form: BookingForm; + renewalValid: boolean | null; + renewalValidating: boolean; + onValidate: () => void; +}) { + const contractType = form.watch("contractType"); + const draftContractId = form.watch("draftContractId"); + const previousContractRef = form.watch("previousContractRef"); + const errors = form.formState.errors; + + return ( +
+ + + ( + +
+ { + field.onChange("new"); + form.clearErrors(["contractType", "previousContractRef"]); + if (!draftContractId) { + form.setValue("draftContractId", genContractId(), { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + > +
+ +
+

New Contract

+

+ Blank contract form. A draft ID is auto-generated. +

+ {field.value === "new" && draftContractId && ( +

+ {draftContractId} +

+ )} +
+ + { + field.onChange("renewal"); + form.clearErrors("contractType"); + }} + > +
+ +
+

Contract Renewal

+

+ Enter a previous reference to auto-populate historical + parameters. +

+
+
+ +
+ )} + /> + + {contractType === "renewal" && ( +
+ ( + + + Previous Contract Reference Number + +
+ + +
+ +
+ )} + /> + {renewalValid === true && ( + + Contract found. Company details, route, and wagon + preferences will be pre-filled. + + )} + {renewalValid === false && ( + + Contract Reference Number not found or unauthorized. Try{" "} + EDR-2024-10001. + + )} +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx new file mode 100644 index 000000000..1e4350e1b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -0,0 +1,72 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { Package, Train } from "lucide-react"; +import { Badge, Field, FieldError } from "@edr/ui-common"; +import { type BookingFormValues } from "./schema"; +import { OptionCard, OptionFieldError, StepHeader } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step2ServiceType({ form }: { form: BookingForm }) { + const serviceType = form.watch("serviceType"); + + return ( +
+ + + ( + +
+ field.onChange("rail")} + > +
+ +
+

Rail Transport Only

+

+ Rail transport along the EDR corridor, with optional + first/last mile trucking. +

+ + Option A + +
+ + field.onChange("rail_forwarding")} + > +
+ +
+

+ Rail Transport & Freight Forwarding +

+

+ Rail transport plus documentation, customs liaison, and a + dedicated coordinator. +

+ + Option B + +
+
+ +
+ )} + /> + +

+ Customs and Clearance Service cannot be selected independently. It must + be bundled with a Rail Transport service. +

+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx new file mode 100644 index 000000000..369eb03f1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx @@ -0,0 +1,148 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { Field, FieldError, Input, Switch } from "@edr/ui-common"; +import { type BookingFormValues } from "./schema"; +import { OptionCard, StepHeader } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step3FirstLastMile({ form }: { form: BookingForm }) { + const firstMileEnabled = form.watch("firstMileEnabled"); + const lastMileEnabled = form.watch("lastMileEnabled"); + const equipmentReturn = form.watch("equipmentReturn"); + + return ( +
+ + +
+
+ ( +
+
+

First Mile - Pick-up

+

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

+
+ { + field.onChange(value); + if (!value) { + form.setValue("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("deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {lastMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+

Equipment Return

+

+ Declare whether the container asset will be returned after + unloading. +

+ ( +
+ field.onChange("with_return")} + > +

With Return

+

+ Container returned to EDR after unloading. +

+
+ field.onChange("without_return")} + > +

Without Return

+

+ Container retained by the customer after delivery. +

+
+
+ )} + /> +
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx new file mode 100644 index 000000000..7fc5b7b02 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -0,0 +1,124 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { Flame, MapPin, Snowflake } from "lucide-react"; +import { Field, Separator, Switch } from "@edr/ui-common"; +import { + type BookingFormValues, + getRouteDirection, + STATIONS, +} from "./schema"; +import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step4Route({ form }: { form: BookingForm }) { + const originYard = form.watch("originYard"); + const destinationYard = form.watch("destinationYard"); + const direction = getRouteDirection(originYard, destinationYard); + 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 (Ethiopia to Djibouti)", + import: "Import workflow (Djibouti to Ethiopia)", + domestic: "Domestic corridor", + }; + + return ( +
+ + +
+ Route +
+ ( + + s !== destinationYard)} + /> + + )} + /> + ( + + s !== originYard)} + /> + + )} + /> +
+ {direction && ( +
+ + {directionLabel[direction]} +
+ )} +
+ + + +
+ ( +
+
+ +
+

Hazardous Material

+

+ Applies a Hazard Surcharge to the final bill. +

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

Refrigerated Cargo

+

+ Temperature-controlled transport applies a Refrigerator + Surcharge. +

+
+
+ +
+ )} + /> +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx new file mode 100644 index 000000000..f4fee0bf8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -0,0 +1,430 @@ +import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; +import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; +import { Button, Field, FieldError, FieldLabel, Input, Separator } from "@edr/ui-common"; +import { + BREAK_BULK_TYPES, + BULK_COMMODITIES, + type BookingFormValues, + type RouteDirection, +} from "./schema"; +import { + AlertBox, + OptionCard, + SelectField, + SelectOptions, + StepHeader, + StepLabel, +} from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step5CargoDetails({ + form, + direction, +}: { + form: BookingForm; + direction: RouteDirection; +}) { + const cargoType = form.watch("cargoType"); + const freightType = form.watch("freightType"); + const bulkCommodity = form.watch("bulkCommodity"); + const breakBulkType = form.watch("breakBulkType"); + const containers = form.watch("containers"); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "containers", + }); + + function getOverweightAlert( + type: "20ft" | "40ft", + vgm: number, + ): string | null { + if (type === "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 (type === "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 ( +
+ + +
+ Cargo Type * + ( + +
+ { + field.onChange("container"); + form.setValue("freightType", "", { shouldDirty: true }); + form.setValue("cargoWeight", "", { shouldDirty: true }); + }} + > +
+ +
+

Container

+

+ Pre-packed containerized cargo (20ft / 40ft). +

+
+ { + field.onChange("bulk"); + form.setValue("containers", [{ type: "20ft", qty: 1, vgm: 0 }], { shouldDirty: true }); + }} + > +
+ +
+

Bulk

+

+ Bulk commodities or break-bulk cargo. +

+
+
+ +
+ )} + /> +
+ + {cargoType === "bulk" && ( + <> + +
+ Freight Type * + ( + +
+ field.onChange("bulk")} + > +

Bulk

+

+ Coffee, fertilizer, grain, ore, etc. +

+
+ field.onChange("break_bulk")} + > +

Break-Bulk

+

+ Machinery, vehicles, project cargo, etc. +

+
+
+ +
+ )} + /> + + {freightType === "bulk" && ( +
+ ( + + + + )} + /> + {bulkCommodity === "Others" && ( + ( + + + + + )} + /> + )} +
+ )} + + {freightType === "break_bulk" && ( +
+ ( + + + + )} + /> + {breakBulkType === "Others" && ( + ( + + + + + )} + /> + )} +
+ )} +
+ + + +
+ Weight + ( + + + Total Cargo Weight - VGM (Tons) * + +
+ + +
+ +
+ )} + /> +
+ + )} + + {cargoType === "container" && ( + <> + +
+
+ Container Configuration + +
+ + {direction && ( +

+ + Route detected as{" "} + + {direction} + {" "} + workflow +

+ )} + + {fields.map((field, index) => { + const containerType = containers[index]?.type; + const vgm = containers[index]?.vgm ?? 0; + const alert = getOverweightAlert(containerType, vgm); + + return ( +
+
+

+ Container #{index + 1} +

+ {fields.length > 1 && ( + + )} +
+ + ( + + Container Type * +
+ {[ + { + val: "20ft" as const, + label: "20ft Container (TEU)", + limit: + direction === "export" + ? "Max 25t per container" + : "Max 20t per container", + }, + { + val: "40ft" as const, + label: "40ft Container (FEU)", + limit: "Max 32.5t per container", + }, + ].map((ct) => ( + typeField.onChange(ct.val)} + > +
+ +

{ct.label}

+
+

+ {ct.limit} +

+
+ ))} +
+ +
+ )} + /> + +
+ ( + + Quantity * +
+ + + qtyField.onChange( + Math.max(1, parseInt(e.target.value) || 1), + ) + } + onBlur={qtyField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + className="text-center" + min="1" + /> + +
+ +
+ )} + /> + + ( + + VGM (Tons) * + + vgmField.onChange( + parseFloat(e.target.value) || 0, + ) + } + onBlur={vgmField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + placeholder="e.g. 18.5" + min="0" + step="0.1" + /> + + + )} + /> +
+ + {alert && ( + + Overweight Alert: {alert} + + )} +
+ ); + })} +
+ + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx new file mode 100644 index 000000000..0bae2917e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx @@ -0,0 +1,139 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { AlertTriangle } from "lucide-react"; +import { Separator } from "@edr/ui-common"; +import { + type BookingFormValues, + type WagonCalcResult, +} from "./schema"; +import { AlertBox, OptionCard, StepHeader, StepLabel } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step6WagonAllocation({ + form, + wagons, +}: { + form: BookingForm; + wagons: WagonCalcResult | null; +}) { + const containers = form.watch("containers") ?? []; + const totalContainers = containers.reduce((sum, c) => sum + (c.qty || 0), 0); + const containerSummary = containers + .filter((c) => c.qty > 0) + .map((c) => `${c.qty} × ${c.type}`) + .join(", "); + const consolidationEnabled = form.watch("consolidationEnabled"); + + return ( +
+ + + {!wagons ? ( + + Complete the container configuration in the previous step to see wagon + allocation. + + ) : ( + <> +
+
+

+ {wagons.totalWagons} +

+

+ Wagons Required +

+
+
+

{totalContainers}

+

+ {containerSummary || "Containers"} +

+
+
+

{wagons.sharedWagons}

+

Shared Slots

+
+
+ +
+ Wagon Layout +
+ {Array.from({ length: wagons.totalWagons }, (_, index) => { + const isOdd = + wagons.hasOddUnit && index === wagons.totalWagons - 1; + return ( +
+ {isOdd ? "1 × 20ft (1/2)" : "2 × 20ft"} +
+ ); + })} +
+
+ +

+ {containers.some((c) => c.type === "40ft") + ? "1 × 40ft = 1 Rail Wagon" + : `CEILING(${totalContainers} / 2) = ${wagons.totalWagons} Rail Wagon${wagons.totalWagons > 1 ? "s" : ""} — 2 × 20ft = 1 Wagon`} +

+ + {wagons.hasOddUnit && ( + <> + +
+
+ +

+ Consolidation Option (US-07) +

+
+

+ You have 1 unpaired 20ft container. Opt in to share a wagon + slot with another shipper to optimise costs, or request a + dedicated wagon. +

+ ( +
+ field.onChange(true)} + > +

+ Allow Consolidation +

+

+ Share a wagon slot. Billing split with co-loader. +

+
+ field.onChange(false)} + > +

Dedicated Wagon

+

+ Exclusive slot. Standard single-party billing. +

+
+
+ )} + /> +
+ + )} + + )} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step7-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step7-documents.tsx new file mode 100644 index 000000000..a4ac23a4b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step7-documents.tsx @@ -0,0 +1,65 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { Field, FieldError, SmartFileInput } from "@edr/ui-common"; +import { + BOOKING_DOCS_SETTING, + REQUIRED_DOC_KEYS, + type BookingFormValues, +} from "./schema"; +import { getUploadedRequiredCount, StepHeader } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step7Documents({ form }: { form: BookingForm }) { + const documents = form.watch("documents"); + const uploadedRequired = getUploadedRequiredCount(documents); + const documentErrors = form.formState.errors.documents as + | Record + | undefined; + const smartFileErrors = Object.fromEntries( + Object.entries(documentErrors ?? {}).map(([key, value]) => [ + key, + value?.message ?? "", + ]), + ); + + return ( +
+ + +
+
+ {uploadedRequired}/{REQUIRED_DOC_KEYS.length} +
+

+ {uploadedRequired < REQUIRED_DOC_KEYS.length + ? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.` + : "All mandatory documents uploaded. Power of Attorney is optional."} +

+
+ + ( + + field.onChange(value)} + errors={smartFileErrors} + /> + + + )} + /> +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx new file mode 100644 index 000000000..75a18fe9a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -0,0 +1,285 @@ +import { Controller, type UseFormReturn } from "react-hook-form"; +import { Check } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Field, + FieldError, + FieldLabel, + Textarea, +} from "@edr/ui-common"; +import { + REQUIRED_DOC_KEYS, + type BookingFormValues, + type RouteDirection, + type WagonCalcResult, +} from "./schema"; +import { getUploadedRequiredCount, StepHeader } from "./shared"; + +type BookingForm = UseFormReturn; + +export function Step8Review({ + form, + setStep, + wagons, + direction, +}: { + form: BookingForm; + setStep: (step: number) => void; + wagons: WagonCalcResult | null; + direction: RouteDirection; +}) { + const values = form.watch(); + const errors = form.formState.errors; + + function Row({ + label, + value, + target, + }: { + label: string; + value: string; + target: number; + }) { + return ( +
+
+

{label}

+

{value || "-"}

+
+ +
+ ); + } + + const containerSummary = + values.cargoType === "container" && values.containers.length > 0 + ? values.containers + .filter((c) => c.qty > 0) + .map((c) => `${c.qty} × ${c.type}`) + .join(", ") + : ""; + const totalVgm = + values.cargoType === "container" + ? values.containers.reduce((sum, c) => sum + (c.qty || 0) * (c.vgm || 0), 0) + : 0; + + const cargoValue = + values.cargoType === "container" + ? containerSummary + : values.freightType === "bulk" + ? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}` + : values.freightType === "break_bulk" + ? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}` + : ""; + const uploadedCount = getUploadedRequiredCount(values.documents); + + return ( +
+ + +
+ + + + Contract & Service + + + + + + + + + + + + + First & Last Mile + + + + + + + + + + + + + Route & Cargo + + + + ${values.destinationYard}`} + target={4} + /> + + + + + + + + + + + Container & Wagons + + + + + 0 ? `${totalVgm.toFixed(1)} tons` : ""} + target={5} + /> + 1 ? "s" : ""}` + : "" + } + target={6} + /> + + + +
+ + ( + + Additional Notes +