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..04f3f6621 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,19 @@ 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 + .string() + .refine((q) => !isNaN(+q), "Enter a valid Number") + .refine((qty) => Number(qty) >= 1, "Must be greater than 0"), + vgm: z + .string() + .refine((vgm) => !isNaN(+vgm), "Enter a valid Number") + .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), + }), + ), consolidationEnabled: z.boolean(), documents: z.record(z.string(), fileValueSchema), notes: z.string(), @@ -342,31 +335,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 +406,7 @@ export const initialBookingFormValues: BookingFormValues = { breakBulkTypeOther: "", isHazardous: false, isRefrigerated: false, - containerType: "", - quantity: "1", - vgm: "", + containers: [{ type: "20ft", qty: "1", vgm: "" }], consolidationEnabled: false, documents: {}, notes: "", @@ -441,9 +432,7 @@ export const stepFields: Record> = { "bulkCommodityOther", "breakBulkType", "breakBulkTypeOther", - "containerType", - "quantity", - "vgm", + "containers", ], 6: ["consolidationEnabled"], 7: ["documents"], @@ -452,10 +441,23 @@ export const stepFields: Record> = { export type RouteDirection = "import" | "export" | "domestic" | null; +export interface ContainerConfig { + type: "20ft" | "40ft"; + qty: string; + vgm: string; +} + +export interface WagonConfig { + type: "20ft" | "40ft"; +} + export interface WagonCalcResult { totalWagons: number; hasOddUnit: boolean; sharedWagons: number; + wagonLayout: WagonConfig[]; + ft40Wagons: number; + ft20Wagons: number; } export function genContractId(): string { @@ -477,15 +479,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 { + 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 wagonLayout: WagonConfig[] = []; + let hasOddUnit = Ft20Wagons % 2 === 1; + let sharedWagons = Math.floor(Ft20Wagons / 2); - const pairs = Math.floor(qty / 2); - const odd = qty % 2; - return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs }; + return { + totalWagons: sharedWagons + Ft40Wagons, + hasOddUnit, + sharedWagons, + ft40Wagons: Ft40Wagons, + ft20Wagons: Ft20Wagons, + wagonLayout, + }; } 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..d0c8b7b9a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -0,0 +1,119 @@ +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 ( +
+ + +
+
+ ( + + 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..9f02e3ced --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -0,0 +1,438 @@ +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(e.target.value) + } + onBlur={qtyField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + className="text-center" + 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} + + )} +
+ ); + })} +
+ + )} +
+ ); +} 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..c58b34241 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx @@ -0,0 +1,111 @@ +import { type UseFormReturn } from "react-hook-form"; +import { type BookingFormValues, type WagonCalcResult } from "./schema"; +import { AlertBox, 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 + Number(c.qty || 0), + 0, + ); + const containerSummary = containers + .filter((c) => +c.qty > 0) + .map((c) => `${c.qty} × ${c.type}`) + .join(", "); + 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 +
+ {new Array(wagons.ft40Wagons).fill(0).map((_, index) => ( +
+ 1 × 40ft +
+ ))} + {new Array(wagons.sharedWagons).fill(0).map((_, index) => ( +
+ 2 × 20ft +
+ ))} + {wagons.hasOddUnit && ( +
+ 1 × 20ft +
+ )} +
+
+ + {wagons.hasOddUnit && ( + <> + +
+
+

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. +

+
+
+
+ + )} + + )} +
+ ); +} 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 +