From 74333e5e4209f07e46829b92ba8c5539ad829f84 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 22 May 2026 10:36:44 +0300 Subject: [PATCH 01/13] fix: incorrect NewBookingPage --- .../src/pages/bookings/BookingDetailPage.tsx | 32 +++---------------- .../src/pages/bookings/BookingsPage.tsx | 30 ++--------------- 2 files changed, 8 insertions(+), 54 deletions(-) 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 cd1bd1e99..7eb038196 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -14,14 +14,9 @@ import { } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import NewBookingPage from "./NewBookingPage"; import DeleteBookingDialog from "./DeleteBookingDialog"; import { getBookingById, type BookingStatus } from "./bookings.mock"; -import { - Button, - Card, - CardContent, -} from "@edr/ui-common"; +import { Button, Card } from "@edr/ui-common"; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); @@ -89,26 +84,7 @@ export default function BookingDetailPage() {
- - - + - {booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? ( + {booking.transportMode === "Multimodal" && + booking.legs && + booking.legs.length > 0 ? (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx index e755c174b..ad7a6cf92 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { ArrowRight, Clock, @@ -15,7 +15,6 @@ import { } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import NewBookingPage from "./NewBookingPage"; import DeleteBookingDialog from "./DeleteBookingDialog"; import { bookings, type BookingStatus } from "./bookings.mock"; import { @@ -125,29 +124,6 @@ export default function BookingsPage() { View - - e.preventDefault()}> - - Edit - -
- + - +
From 9fbe27e634512d001aa3de2069309dff9ff8e4f0 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 22 May 2026 10:45:13 +0300 Subject: [PATCH 02/13] chore: add field component from shadcn to ui-common --- packages/ui-common/src/components/field.tsx | 246 ++++++++++++++++++ .../ui-common/src/components/separator.tsx | 28 ++ packages/ui-common/src/index.ts | 3 +- 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/ui-common/src/components/field.tsx create mode 100644 packages/ui-common/src/components/separator.tsx diff --git a/packages/ui-common/src/components/field.tsx b/packages/ui-common/src/components/field.tsx new file mode 100644 index 000000000..d90a87c16 --- /dev/null +++ b/packages/ui-common/src/components/field.tsx @@ -0,0 +1,246 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../lib/utils" +import { Label } from "./label" +import { Separator } from "./separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +
); @@ -521,7 +558,8 @@ function Step2({ // ── Step 3: First & Last Mile ────────────────────────────────────────────────── function Step3({ - d, set, + d, + set, }: { d: FormData; set: (k: K, v: FormData[K]) => void; @@ -545,7 +583,10 @@ function Step3({ Truck pick-up from your premises to the origin rail yard.

- set("firstMileEnabled", v)} /> + set("firstMileEnabled", v)} + />
{d.firstMileEnabled && (
@@ -564,10 +605,14 @@ function Step3({

Last Mile – Delivery

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

- set("lastMileEnabled", v)} /> + set("lastMileEnabled", v)} + />
{d.lastMileEnabled && (
@@ -584,7 +629,8 @@ function Step3({

Equipment Return

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

(k: K, v: FormData[K]) => void; @@ -635,9 +682,9 @@ function Step4({ return (
-

Route & Cargo

+

Route

- Define the route, weight, and cargo classification. + Select the origin and destination yards.

@@ -646,24 +693,38 @@ function Step4({ Route
- set("originYard", v)} - options={STATIONS.filter((s) => s !== d.destinationYard).map((s) => ({ - value: s, label: s, - }))} - placeholder="Select origin..." - /> + onValueChange={(v) => set("originYard", v)} + > + + + + + {STATIONS.filter((s) => s !== d.destinationYard).map((s) => ( + + {s} + + ))} + + - set("destinationYard", v)} - options={STATIONS.filter((s) => s !== d.originYard).map((s) => ({ - value: s, label: s, - }))} - placeholder="Select destination..." - /> + onValueChange={(v) => set("destinationYard", v)} + > + + + + + {STATIONS.filter((s) => s !== d.originYard).map((s) => ( + + {s} + + ))} + +
{direction && ( @@ -678,90 +739,6 @@ function Step4({ - {/* Weight */} -
- Weight - -
- - set("cargoWeight", e.target.value)} - className="pl-9" - min="0" - step="0.01" - /> -
-
-
- - - - {/* Freight Classification */} -
- Freight Type * -
- set("freightType", "bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- set("freightType", "break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
-
- - {d.freightType === "bulk" && ( -
- { set("bulkCommodity", v); if (v !== "Others") set("bulkCommodityOther", ""); }} - options={BULK_COMMODITIES.map((c) => ({ value: c, label: c }))} - placeholder="Select commodity *" - /> - {d.bulkCommodity === "Others" && ( - set("bulkCommodityOther", e.target.value)} - /> - )} -
- )} - - {d.freightType === "break_bulk" && ( -
- { set("breakBulkType", v); if (v !== "Others") set("breakBulkTypeOther", ""); }} - options={BREAK_BULK_TYPES.map((c) => ({ value: c, label: c }))} - placeholder="Select type *" - /> - {d.breakBulkType === "Others" && ( - set("breakBulkTypeOther", e.target.value)} - /> - )} -
- )} -
- - - {/* Modifiers */}
@@ -774,7 +751,10 @@ function Step4({

- set("isHazardous", v)} /> + set("isHazardous", v)} + />
@@ -782,11 +762,15 @@ function Step4({

Refrigerated Cargo

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

- set("isRefrigerated", v)} /> + set("isRefrigerated", v)} + />
@@ -796,7 +780,9 @@ function Step4({ // ── Step 5: Container Configuration ─────────────────────────────────────────── function Step5({ - d, set, direction, + d, + set, + direction, }: { d: FormData; set: (k: K, v: FormData[K]) => void; @@ -817,79 +803,247 @@ function Step5({ return (
-

Container Configuration

+

Cargo Details

- Select container type, quantity, and Verified Gross Mass per container. + Define your cargo type, weight, and container configuration.

-
- {([ - { - 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) => ( - set("containerType", ct.val)}> -
+ {/* Cargo Type */} +
+ Cargo Type * +
+ set("cargoType", "container")} + > +
-

{ct.label}

-

{ct.limit}

+

Container

+

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

- ))} + set("cargoType", "bulk")} + > +
+ +
+

Bulk

+

+ Bulk commodities or break-bulk cargo. +

+
+
- {direction && ( -

- - Route detected as {direction} workflow -

+ {/* Bulk section */} + {d.cargoType === "bulk" && ( + <> + +
+ Freight Type * +
+ set("freightType", "bulk")} + > +

Bulk

+

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

+
+ set("freightType", "break_bulk")} + > +

Break-Bulk

+

+ Machinery, vehicles, project cargo, etc. +

+
+
+ + {d.freightType === "bulk" && ( +
+ + {d.bulkCommodity === "Others" && ( + set("bulkCommodityOther", e.target.value)} + /> + )} +
+ )} + + {d.freightType === "break_bulk" && ( +
+ + {d.breakBulkType === "Others" && ( + set("breakBulkTypeOther", e.target.value)} + /> + )} +
+ )} +
+ + + + {/* Weight */} +
+ Weight + +
+ + set("cargoWeight", e.target.value)} + className="pl-9" + min="0" + step="0.01" + /> +
+
+
+ )} - + {/* Container section */} + {d.cargoType === "container" && ( + <> + +
+ Container Configuration +
+ {[ + { + 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) => ( + set("containerType", ct.val)} + > +
+ +

{ct.label}

+
+

{ct.limit}

+
+ ))} +
-
- -
- - set("quantity", e.target.value)} - className="text-center" - min="1" - /> - + {direction && ( +

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

+ )} + +
+ +
+ + set("quantity", e.target.value)} + className="text-center" + min="1" + /> + +
+
+ + set("vgm", e.target.value)} + min="0" + step="0.1" + /> + +
-
- - set("vgm", e.target.value)} - min="0" - step="0.1" - /> - -
- {overweightAlert && Overweight Alert: {overweightAlert}} + {overweightAlert && ( + + Overweight Alert: {overweightAlert} + + )} + + )}
); } @@ -897,7 +1051,9 @@ function Step5({ // ── Step 6: Wagon Allocation ─────────────────────────────────────────────────── function Step6({ - d, set, wagons, + d, + set, + wagons, }: { d: FormData; set: (k: K, v: FormData[K]) => void; @@ -916,18 +1072,25 @@ function Step6({ {!wagons ? ( - Complete the container configuration in the previous step to see wagon allocation. + Complete the container configuration in the previous step to see wagon + allocation. ) : ( <>
-

{wagons.totalWagons}

-

Wagons Required

+

+ {wagons.totalWagons} +

+

+ Wagons Required +

{qty}

-

{d.containerType} Containers

+

+ {d.containerType} Containers +

{wagons.sharedWagons}

@@ -943,15 +1106,16 @@ function Step6({ return (
{isOdd ? `1 × ${d.containerType} (½)` - : d.containerType === "20ft" ? "2 × 20ft" : "1 × 40ft"} + : d.containerType === "20ft" + ? "2 × 20ft" + : "1 × 40ft"}
); })} @@ -971,11 +1135,14 @@ function Step6({
-

Consolidation Option (US-07)

+

+ Consolidation Option (US-07) +

- You have 1 unpaired 20ft container. Opt in to share a wagon slot with another - shipper to optimise costs (2 × 20ft = 1 Wagon), or request a dedicated wagon. + You have 1 unpaired 20ft container. Opt in to share a wagon + slot with another shipper to optimise costs (2 × 20ft = 1 + Wagon), or request a dedicated wagon.

(k: K, v: FormData[K]) => void; @@ -1023,19 +1191,21 @@ function Step7Docs({ return (
-

Compliance Documents

+

+ Compliance Documents +

- Upload your company's legal credentials for EDR contract eligibility verification (US-04). + Upload your company's legal credentials for EDR contract eligibility + verification (US-04).

{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
@@ -1058,7 +1228,11 @@ function Step7Docs({ // ── Step 8: Review & Submit ──────────────────────────────────────────────────── function Step8Review({ - d, set, setStep, wagons, direction, + d, + set, + setStep, + wagons, + direction, }: { d: FormData; set: (k: K, v: FormData[K]) => void; @@ -1066,7 +1240,15 @@ function Step8Review({ wagons: WagonCalcResult | null; direction: RouteDirection; }) { - function Row({ label, value, target }: { label: string; value: string; target: number }) { + function Row({ + label, + value, + target, + }: { + label: string; + value: string; + target: number; + }) { return (
@@ -1088,8 +1270,8 @@ function Step8Review({ d.freightType === "bulk" ? `Bulk — ${d.bulkCommodity === "Others" ? d.bulkCommodityOther : d.bulkCommodity}` : d.freightType === "break_bulk" - ? `Break-Bulk — ${d.breakBulkType === "Others" ? d.breakBulkTypeOther : d.breakBulkType}` - : ""; + ? `Break-Bulk — ${d.breakBulkType === "Others" ? d.breakBulkTypeOther : d.breakBulkType}` + : ""; const uploadedCount = REQUIRED_DOC_KEYS.filter((k) => { const f = d.documents[k]; @@ -1114,11 +1296,25 @@ function Step8Review({ - - + + @@ -1143,7 +1339,11 @@ function Step8Review({ /> @@ -1156,13 +1356,36 @@ function Step8Review({ - - - - + + + + @@ -1175,11 +1398,23 @@ function Step8Review({ - - + + 1 ? "s" : ""}` : ""} + value={ + wagons + ? `${wagons.totalWagons} wagon${wagons.totalWagons > 1 ? "s" : ""}` + : "" + } target={6} /> set("termsAccepted", !d.termsAccepted)} - className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${ - d.termsAccepted ? "border-primary bg-primary" : "border-input" - }`} + className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${d.termsAccepted ? "border-primary bg-primary" : "border-input" + }`} > - {d.termsAccepted && } + {d.termsAccepted && ( + + )} I confirm the information is accurate and agree to EDR's{" "} - freight contract terms and conditions. + + freight contract terms and conditions + + .
@@ -1250,7 +1489,9 @@ export default function NewBookingPage() { setRenewalValid(null); setTimeout(() => { setRenewalValidating(false); - setRenewalValid(MOCK_VALID_CONTRACTS.includes(data.previousContractRef.toUpperCase())); + setRenewalValid( + MOCK_VALID_CONTRACTS.includes(data.previousContractRef.toUpperCase()), + ); }, 1200); } @@ -1267,18 +1508,25 @@ export default function NewBookingPage() { if (data.lastMileEnabled && !data.deliveryAddress) return false; return true; case 4: - if (!data.originYard || !data.destinationYard || !data.cargoWeight || !data.freightType) - return false; - if (data.freightType === "bulk") { - if (!data.bulkCommodity) return false; - if (data.bulkCommodity === "Others" && !data.bulkCommodityOther) return false; - } - if (data.freightType === "break_bulk") { - if (!data.breakBulkType) return false; - if (data.breakBulkType === "Others" && !data.breakBulkTypeOther) return false; - } + if (!data.originYard || !data.destinationYard) return false; return true; case 5: + if (!data.cargoType) return false; + if (data.cargoType === "bulk") { + if (!data.freightType) return false; + if (data.freightType === "bulk") { + if (!data.bulkCommodity) return false; + if (data.bulkCommodity === "Others" && !data.bulkCommodityOther) + return false; + } + if (data.freightType === "break_bulk") { + if (!data.breakBulkType) return false; + if (data.breakBulkType === "Others" && !data.breakBulkTypeOther) + return false; + } + if (!data.cargoWeight) return false; + return true; + } return !!(data.containerType && data.quantity && data.vgm); case 6: return true; @@ -1309,8 +1557,8 @@ export default function NewBookingPage() {

Contract Submitted

- Your request is queued for review by EDR Line Staff. You will be notified once - approved. + Your request is queued for review by EDR Line Staff. You will be + notified once approved.

{data.draftContractId || data.previousContractRef} @@ -1321,41 +1569,41 @@ export default function NewBookingPage() { } return ( -

+
{/* Sticky step indicator */}
{STEPS.map((s, idx) => (
s.id + className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${step > s.id ? "bg-primary text-primary-foreground" : step === s.id - ? "border-2 border-primary text-primary" - : "bg-muted text-muted-foreground" - }`} + ? "border-2 border-primary text-primary" + : "bg-muted text-muted-foreground" + }`} > {step > s.id ? : s.id}
= s.id ? "text-foreground" : "text-muted-foreground" - }`} + className={`hidden text-[10px] font-medium lg:block ${step >= s.id ? "text-foreground" : "text-muted-foreground" + }`} > {s.short}
{idx < STEPS.length - 1 && (
s.id ? "bg-primary" : "bg-border" - }`} + className={`mx-1 h-0.5 flex-1 rounded-full transition-colors ${step > s.id ? "bg-primary" : "bg-border" + }`} /> )} @@ -1406,9 +1654,11 @@ export default function NewBookingPage() { Back
- {step} / {STEPS.length} {step < STEPS.length ? ( - From 6287022a536e3d0a0bfd4556d39fb907a2a2b711 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 22 May 2026 15:42:56 +0300 Subject: [PATCH 06/13] style: improve the switch component --- packages/ui-common/src/components/switch.tsx | 18 +++++++++--------- packages/ui-common/src/index.ts | 1 + 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/ui-common/src/components/switch.tsx b/packages/ui-common/src/components/switch.tsx index 76190b7c9..83829ae92 100644 --- a/packages/ui-common/src/components/switch.tsx +++ b/packages/ui-common/src/components/switch.tsx @@ -1,33 +1,33 @@ -import * as React from "react" -import { Switch as SwitchPrimitive } from "radix-ui" +import * as React from "react"; +import { Switch as SwitchPrimitive } from "radix-ui"; -import { cn } from "../lib/utils" +import { cn } from "../lib/utils"; function Switch({ className, size = "default", ...props }: React.ComponentProps & { - size?: "sm" | "default" + size?: "sm" | "default" | "lg"; }) { return ( - ) + ); } -export { Switch } +export { Switch }; diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts index ca2df73bf..b2c2978bd 100644 --- a/packages/ui-common/src/index.ts +++ b/packages/ui-common/src/index.ts @@ -32,4 +32,5 @@ export * from "./components/dialog"; export * from "./components/SmartFileInput"; export * from "./components/select"; export * from "./components/switch"; +export * from "./components/separator"; export * from "./components/field"; From 4691f5ad3f1591e4b11050deec1adc8f95dc10ec Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 22 May 2026 15:43:20 +0300 Subject: [PATCH 07/13] feat: enhance the new booking form with RHC --- .../src/pages/bookings/NewBookingPage.tsx | 1718 ++--------------- .../new-booking-form/StepIndicator.tsx | 41 + .../pages/bookings/new-booking-form/schema.ts | 491 +++++ .../bookings/new-booking-form/shared.tsx | 157 ++ .../pages/bookings/new-booking-form/steps.tsx | 1303 +++++++++++++ 5 files changed, 2124 insertions(+), 1586 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/StepIndicator.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/shared.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/steps.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 84a3b9c69..2fe104665 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,1549 +1,125 @@ -import { Fragment, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { - AlertTriangle, - Check, - CheckCircle2, - ChevronLeft, - ChevronRight, - FileText, - Flame, - Info, - Loader2, - MapPin, - Package, - RefreshCw, - Snowflake, - Train, - Weight, - XCircle, -} from "lucide-react"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SmartFileInput, - Switch, - Textarea, -} from "@edr/ui-common"; +import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; - -// ── Mock Constants ───────────────────────────────────────────────────────────── - -const STATIONS = [ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", - "Aysha", - "Ali Sabieh", - "Holhol", - "Djibouti City", -]; - -const ETHIOPIA_STATIONS = new Set([ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", -]); - -const BULK_COMMODITIES = [ - "Coffee", - "Beans", - "Fertilizer", - "Sugar", - "Oil", - "Livestock", - "Steel", - "Others", -]; - -const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"]; - -const MOCK_VALID_CONTRACTS = [ - "EDR-2024-10001", - "EDR-2024-10002", - "EDR-2023-88123", - "EDR-2022-55442", -]; - -// US-04 compliance documents (TIN, Business License, Registration, National ID, optional PoA) -const BOOKING_DOCS_SETTING = { - id: "booking-compliance", - createdAt: "", - updatedAt: "", - deletedAt: null, - code: "booking_compliance_docs", - label: "Compliance Documents", - description: - "Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.", - entity: "booking" as const, - fields: [ - { - id: "f1", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: - "Tax Identification Number certificate issued by ERCA (10-digit TIN).", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 1, - }, - { - id: "f2", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_license", - fileLabel: "Business / Investment License", - helpText: - "Current business or investment license issued by the relevant government authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 2, - }, - { - id: "f3", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_registration", - fileLabel: "Business Registration Certificate", - helpText: "Certificate of registration from the relevant authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - 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: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney (PoA)", - helpText: - "Required only if a representative is signing on behalf of the company.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 5, - }, - ], -}; - -const REQUIRED_DOC_KEYS = [ - "tin_certificate", - "business_license", - "business_registration", - "national_id", -]; - -const STEPS = [ - { id: 1, label: "Contract Type", short: "Contract" }, - { id: 2, label: "Service Type", short: "Service" }, - { id: 3, label: "First & Last Mile", short: "Mile" }, - { id: 4, label: "Route", short: "Route" }, - { id: 5, label: "Cargo Details", short: "Cargo" }, - { id: 6, label: "Wagon Allocation", short: "Wagons" }, - { id: 7, label: "Documents", short: "Docs" }, - { id: 8, label: "Review & Submit", short: "Submit" }, -] as const; - -// ── Types ────────────────────────────────────────────────────────────────────── - -type ContractTypeVal = "new" | "renewal" | ""; -type ServiceTypeVal = "rail" | "rail_forwarding" | ""; -type FreightTypeVal = "bulk" | "break_bulk" | ""; -type ContainerTypeVal = "20ft" | "40ft" | ""; -type EquipmentReturn = "with_return" | "without_return"; - -interface FormData { - contractType: ContractTypeVal; - previousContractRef: string; - draftContractId: string; - serviceType: ServiceTypeVal; - firstMileEnabled: boolean; - pickUpAddress: string; - lastMileEnabled: boolean; - deliveryAddress: string; - equipmentReturn: EquipmentReturn; - originYard: string; - destinationYard: string; - cargoType: "container" | "bulk" | ""; - cargoWeight: string; - freightType: FreightTypeVal; - bulkCommodity: string; - bulkCommodityOther: string; - breakBulkType: string; - breakBulkTypeOther: string; - isHazardous: boolean; - isRefrigerated: boolean; - containerType: ContainerTypeVal; - quantity: string; - vgm: string; - consolidationEnabled: boolean; - documents: Record; - notes: string; - termsAccepted: boolean; -} - -const INITIAL_DATA: FormData = { - contractType: "", - previousContractRef: "", - draftContractId: "", - serviceType: "", - firstMileEnabled: false, - pickUpAddress: "", - lastMileEnabled: false, - deliveryAddress: "", - equipmentReturn: "with_return", - originYard: "", - destinationYard: "", - cargoType: "", - cargoWeight: "", - freightType: "", - bulkCommodity: "", - bulkCommodityOther: "", - breakBulkType: "", - breakBulkTypeOther: "", - isHazardous: false, - isRefrigerated: false, - containerType: "", - quantity: "1", - vgm: "", - consolidationEnabled: false, - documents: {}, - notes: "", - termsAccepted: false, -}; - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -function genContractId(): string { - const yr = new Date().getFullYear(); - const n = Math.floor(10000 + Math.random() * 90000); - return `EDR-DRAFT-${yr}-${n}`; -} - -type RouteDirection = "import" | "export" | "domestic" | null; - -function getRouteDirection(origin: string, dest: string): RouteDirection { - if (!origin || !dest) return null; - const oEth = ETHIOPIA_STATIONS.has(origin); - const dEth = ETHIOPIA_STATIONS.has(dest); - if (oEth && !dEth) return "export"; - if (!oEth && dEth) return "import"; - if (oEth && dEth) return "domestic"; - return null; -} - -interface WagonCalcResult { - totalWagons: number; - hasOddUnit: boolean; - sharedWagons: number; -} - -function calcWagons(type: ContainerTypeVal, qty: number): WagonCalcResult { - if (type === "40ft") - return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty }; - const pairs = Math.floor(qty / 2); - const odd = qty % 2; - return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs }; -} - -// ── Shared primitives ────────────────────────────────────────────────────────── - -function OptionCard({ - selected, - onClick, - disabled, - children, -}: { - selected: boolean; - onClick?: () => void; - disabled?: boolean; - children: React.ReactNode; -}) { - return ( - - ); -} - -function AlertBox({ - tone, - children, -}: { - tone: "warning" | "error" | "success" | "info"; - children: React.ReactNode; -}) { - const s = { - warning: "bg-amber-50 border-amber-200 text-amber-800", - error: "bg-red-50 border-red-200 text-red-800", - success: "bg-emerald-50 border-emerald-200 text-emerald-800", - info: "bg-sky-50 border-sky-200 text-sky-800", - }; - const icons = { - warning: , - error: , - success: , - info: , - }; - return ( -
- {icons[tone]} -
{children}
-
- ); -} - -function Field({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} - -function Divider() { - return
; -} - -function StepLabel({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ); -} - -// ── Step 1: Contract Type ────────────────────────────────────────────────────── - -function Step1({ - d, - set, - renewalValid, - renewalValidating, - onValidate, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - renewalValid: boolean | null; - renewalValidating: boolean; - onValidate: () => void; -}) { - return ( -
-
-

Contract Type

-

- New contract or renewal of an existing one. -

-
- -
- { - set("contractType", "new"); - if (!d.draftContractId) set("draftContractId", genContractId()); - }} - > -
- -
-

New Contract

-

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

- {d.contractType === "new" && d.draftContractId && ( -

- {d.draftContractId} -

- )} -
- - set("contractType", "renewal")} - > -
- -
-

Contract Renewal

-

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

-
-
- - {d.contractType === "renewal" && ( -
- -
- set("previousContractRef", e.target.value)} - className="font-mono" - /> - -
-
- {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. - - )} -
- )} -
- ); -} - -// ── Step 2: Service Type ─────────────────────────────────────────────────────── - -function Step2({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - return ( -
-
-

Service Type

-

- Select the service combination you require. -

-
- -
- set("serviceType", "rail")} - > -
- -
-

Rail Transport Only

-

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

- - Option A - -
- - set("serviceType", "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. -

-
- ); -} - -// ── Step 3: First & Last Mile ────────────────────────────────────────────────── - -function Step3({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - return ( -
-
-

First & Last Mile

-

- Configure trucking and container return options. -

-
- -
- {/* First Mile */} -
-
-
-

First Mile – Pick-up

-

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

-
- set("firstMileEnabled", v)} - /> -
- {d.firstMileEnabled && ( -
- set("pickUpAddress", e.target.value)} - /> -
- )} -
- - {/* Last Mile */} -
-
-
-

Last Mile – Delivery

-

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

-
- set("lastMileEnabled", v)} - /> -
- {d.lastMileEnabled && ( -
- set("deliveryAddress", e.target.value)} - /> -
- )} -
- - {/* Equipment Return */} -
-

Equipment Return

-

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

-
- set("equipmentReturn", "with_return")} - > -

With Return

-

- Container returned to EDR after unloading. -

-
- set("equipmentReturn", "without_return")} - > -

Without Return

-

- Container retained by the customer after delivery. -

-
-
-
-
-
- ); -} - -// ── Step 4: Route & Cargo ────────────────────────────────────────────────────── - -function Step4({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - const direction = getRouteDirection(d.originYard, d.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 → Djibouti)", - import: "Import workflow (Djibouti → Ethiopia)", - domestic: "Domestic corridor", - }; - - return ( -
-
-

Route

-

- Select the origin and destination yards. -

-
- - {/* Route */} -
- Route -
- - - - - - -
- {direction && ( -
- - {directionLabel[direction]} -
- )} -
- - - - {/* Modifiers */} -
-
-
- -
-

Hazardous Material

-

- Applies a Hazard Surcharge to the final bill. -

-
-
- set("isHazardous", v)} - /> -
-
-
- -
-

Refrigerated Cargo

-

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

-
-
- set("isRefrigerated", v)} - /> -
-
-
- ); -} - -// ── Step 5: Container Configuration ─────────────────────────────────────────── - -function Step5({ - d, - set, - direction, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - direction: RouteDirection; -}) { - const qty = parseInt(d.quantity) || 1; - const vgm = parseFloat(d.vgm) || 0; - - let overweightAlert: string | null = null; - if (d.containerType === "20ft" && vgm > 0) { - const limit = direction === "export" ? 25 : 20; - if (vgm > limit) - overweightAlert = `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; - } - if (d.containerType === "40ft" && vgm > 32.5) - overweightAlert = `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`; - - return ( -
-
-

Cargo Details

-

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

-
- - {/* Cargo Type */} -
- Cargo Type * -
- set("cargoType", "container")} - > -
- -
-

Container

-

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

-
- set("cargoType", "bulk")} - > -
- -
-

Bulk

-

- Bulk commodities or break-bulk cargo. -

-
-
-
- - {/* Bulk section */} - {d.cargoType === "bulk" && ( - <> - -
- Freight Type * -
- set("freightType", "bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- set("freightType", "break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
-
- - {d.freightType === "bulk" && ( -
- - {d.bulkCommodity === "Others" && ( - set("bulkCommodityOther", e.target.value)} - /> - )} -
- )} - - {d.freightType === "break_bulk" && ( -
- - {d.breakBulkType === "Others" && ( - set("breakBulkTypeOther", e.target.value)} - /> - )} -
- )} -
- - - - {/* Weight */} -
- Weight - -
- - set("cargoWeight", e.target.value)} - className="pl-9" - min="0" - step="0.01" - /> -
-
-
- - )} - - {/* Container section */} - {d.cargoType === "container" && ( - <> - -
- Container Configuration -
- {[ - { - 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) => ( - set("containerType", ct.val)} - > -
- -

{ct.label}

-
-

{ct.limit}

-
- ))} -
- - {direction && ( -

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

- )} - -
- -
- - set("quantity", e.target.value)} - className="text-center" - min="1" - /> - -
-
- - set("vgm", e.target.value)} - min="0" - step="0.1" - /> - -
-
- - {overweightAlert && ( - - Overweight Alert: {overweightAlert} - - )} - - )} -
- ); -} - -// ── Step 6: Wagon Allocation ─────────────────────────────────────────────────── - -function Step6({ - d, - set, - wagons, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - wagons: WagonCalcResult | null; -}) { - const qty = parseInt(d.quantity) || 1; - - return ( -
-
-

Wagon Allocation

-

- System-calculated wagon requirements based on your container profile. -

-
- - {!wagons ? ( - - Complete the container configuration in the previous step to see wagon - allocation. - - ) : ( - <> -
-
-

- {wagons.totalWagons} -

-

- Wagons Required -

-
-
-

{qty}

-

- {d.containerType} Containers -

-
-
-

{wagons.sharedWagons}

-

Shared Slots

-
-
- -
- Wagon Layout -
- {Array.from({ length: wagons.totalWagons }, (_, i) => { - const isOdd = wagons.hasOddUnit && i === wagons.totalWagons - 1; - return ( -
- {isOdd - ? `1 × ${d.containerType} (½)` - : d.containerType === "20ft" - ? "2 × 20ft" - : "1 × 40ft"} -
- ); - })} -
-
- -

- Formula:{" "} - {d.containerType === "40ft" - ? "1 × 40ft = 1 Rail Wagon" - : `CEILING(${qty} ÷ 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 (2 × 20ft = 1 - Wagon), or request a dedicated wagon. -

-
- set("consolidationEnabled", true)} - > -

Allow Consolidation

-

- Share a wagon slot — billing split with co-loader. -

-
- set("consolidationEnabled", false)} - > -

Dedicated Wagon

-

- Exclusive slot — standard single-party billing. -

-
-
-
- - )} - - )} -
- ); -} - -// ── Step 7: Compliance Documents ─────────────────────────────────────────────── - -function Step7Docs({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - const uploadedRequired = REQUIRED_DOC_KEYS.filter((k) => { - const f = d.documents[k]; - if (!f) return false; - return Array.isArray(f) ? f.length > 0 : true; - }).length; - - return ( -
-
-

- Compliance Documents -

-

- Upload your company's legal credentials for EDR contract eligibility - verification (US-04). -

-
- -
-
- {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."} -

-
- - set("documents", val)} - /> -
- ); -} - -// ── Step 8: Review & Submit ──────────────────────────────────────────────────── - -function Step8Review({ - d, - set, - setStep, - wagons, - direction, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - setStep: (n: number) => void; - wagons: WagonCalcResult | null; - direction: RouteDirection; -}) { - function Row({ - label, - value, - target, - }: { - label: string; - value: string; - target: number; - }) { - return ( -
-
-

{label}

-

{value || "—"}

-
- -
- ); - } - - const cargoValue = - d.freightType === "bulk" - ? `Bulk — ${d.bulkCommodity === "Others" ? d.bulkCommodityOther : d.bulkCommodity}` - : d.freightType === "break_bulk" - ? `Break-Bulk — ${d.breakBulkType === "Others" ? d.breakBulkTypeOther : d.breakBulkType}` - : ""; - - const uploadedCount = REQUIRED_DOC_KEYS.filter((k) => { - const f = d.documents[k]; - if (!f) return false; - return Array.isArray(f) ? f.length > 0 : true; - }).length; - - return ( -
-
-

Review & Submit

-

- Confirm your contract request before sending it for EDR staff review. -

-
- -
- - - - Contract & Service - - - - - - - - - - - - - First & Last Mile - - - - - - - - - - - - - Route & Cargo - - - - - - - - - - - - - - - Container & Wagons - - - - - - 1 ? "s" : ""}` - : "" - } - target={6} - /> - - - -
- -
- -