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() {
-
- Edit Booking
-
+
Edit Booking
- {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
-
-
-
+
New Booking
-
+
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 (
+ [data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
+ "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children
+ }
+
+ if (!errors?.length) {
+ return null
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ]
+
+ if (uniqueErrors?.length == 1) {
+ return uniqueErrors[0]?.message
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && {error.message}
+ )}
+
+ )
+ }, [children, errors])
+
+ if (!content) {
+ return null
+ }
+
+ return (
+
+ {content}
+
+ )
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+}
diff --git a/packages/ui-common/src/components/separator.tsx b/packages/ui-common/src/components/separator.tsx
new file mode 100644
index 000000000..8971fe86f
--- /dev/null
+++ b/packages/ui-common/src/components/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "../lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts
index 9177eec0f..4b6dd2b94 100644
--- a/packages/ui-common/src/index.ts
+++ b/packages/ui-common/src/index.ts
@@ -1,4 +1,4 @@
-export { Table } from "./components/table";
+export { Table } from "./components/table";
export type { TableProps, TableColumn } from "./components/Table/Table";
export { FormField } from "./components/Form";
@@ -30,3 +30,4 @@ export * from "./components/dropdown-menu";
export * from "./components/data-table";
export * from "./components/dialog";
export * from "./components/SmartFileInput";
+export * from "./components/field";
From 47bcc82c881b6f249116b20bc6949babde1c9f95 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Fri, 22 May 2026 10:47:12 +0300
Subject: [PATCH 03/13] chore: install react-hook-form, zod and
@hookform/resolvers to freight web portal
---
apps/edr-freight-web/portal/package.json | 3 ++
pnpm-lock.yaml | 38 ++++++++++++++++++++++--
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json
index 81a34f736..9afc896f7 100644
--- a/apps/edr-freight-web/portal/package.json
+++ b/apps/edr-freight-web/portal/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
+ "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui-common": "1.1.1",
"axios": "^1.7.7",
@@ -23,9 +24,11 @@
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",
+ "react-hook-form": "^7.76.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
+ "zod": "^4.4.3",
"zustand": "^5.0.0"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ca17749d4..6aa2017e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -229,6 +229,9 @@ importers:
'@edr/ui-common':
specifier: workspace:*
version: link:../../../packages/ui-common
+ '@hookform/resolvers':
+ specifier: ^5.4.0
+ version: 5.4.0(react-hook-form@7.76.0(react@18.3.1))
'@tanstack/react-query':
specifier: ^5.59.0
version: 5.100.10(react@18.3.1)
@@ -256,6 +259,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
+ react-hook-form:
+ specifier: ^7.76.0
+ version: 7.76.0(react@18.3.1)
react-router-dom:
specifier: ^6.27.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -265,6 +271,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
zustand:
specifier: ^5.0.0
version: 5.0.13(@types/react@18.3.28)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -1324,6 +1333,11 @@ packages:
peerDependencies:
react-hook-form: ^7.55.0
+ '@hookform/resolvers@5.4.0':
+ resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@@ -9252,6 +9266,12 @@ packages:
peerDependencies:
react: 18.3.1
+ react-hook-form@7.76.0:
+ resolution: {integrity: sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: 18.3.1
+
react-i18next@15.7.4:
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies:
@@ -11449,6 +11469,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
@@ -12264,6 +12287,11 @@ snapshots:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.75.0(react@18.3.1)
+ '@hookform/resolvers@5.4.0(react-hook-form@7.76.0(react@18.3.1))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.76.0(react@18.3.1)
+
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -14897,7 +14925,7 @@ snapshots:
'@chakra-ui/react': 3.35.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)
- '@hookform/resolvers': 5.2.2(react-hook-form@7.75.0(react@18.3.1))
+ '@hookform/resolvers': 5.4.0(react-hook-form@7.76.0(react@18.3.1))
'@lottiefiles/react-lottie-player': 3.6.0(react@18.3.1)
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/dates': 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.3.18(react@18.3.1))(dayjs@1.11.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -14972,7 +15000,7 @@ snapshots:
react-day-picker: 9.14.0(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
react-dropzone: 14.4.1(react@18.3.1)
- react-hook-form: 7.75.0(react@18.3.1)
+ react-hook-form: 7.76.0(react@18.3.1)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
react-icons: 5.6.0(react@18.3.1)
react-image-crop: 11.0.10(react@18.3.1)
@@ -21997,6 +22025,10 @@ snapshots:
dependencies:
react: 18.3.1
+ react-hook-form@7.76.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-i18next@15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
@@ -24547,6 +24579,8 @@ snapshots:
zod@3.25.76: {}
+ zod@4.4.3: {}
+
zustand@5.0.13(@types/react@18.3.28)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.28
From 37916a0cae873c70b0ef344107d28e404f1f5a21 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Fri, 22 May 2026 11:37:24 +0300
Subject: [PATCH 04/13] chore: add switch component from shadcn to ui-common
---
packages/ui-common/src/components/switch.tsx | 33 ++++++++++++++++++++
packages/ui-common/src/index.ts | 2 ++
2 files changed, 35 insertions(+)
create mode 100644 packages/ui-common/src/components/switch.tsx
diff --git a/packages/ui-common/src/components/switch.tsx b/packages/ui-common/src/components/switch.tsx
new file mode 100644
index 000000000..76190b7c9
--- /dev/null
+++ b/packages/ui-common/src/components/switch.tsx
@@ -0,0 +1,33 @@
+import * as React from "react"
+import { Switch as SwitchPrimitive } from "radix-ui"
+
+import { cn } from "../lib/utils"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/packages/ui-common/src/index.ts b/packages/ui-common/src/index.ts
index 4b6dd2b94..ca2df73bf 100644
--- a/packages/ui-common/src/index.ts
+++ b/packages/ui-common/src/index.ts
@@ -30,4 +30,6 @@ export * from "./components/dropdown-menu";
export * from "./components/data-table";
export * from "./components/dialog";
export * from "./components/SmartFileInput";
+export * from "./components/select";
+export * from "./components/switch";
export * from "./components/field";
From e9362abecdebddd35c88286ea427ce9c477de135 Mon Sep 17 00:00:00 2001
From: ghost2023
Date: Fri, 22 May 2026 11:38:22 +0300
Subject: [PATCH 05/13] feat(bookings): Rework New Booking flow to support
cargo type selection
---
.../src/pages/bookings/NewBookingPage.tsx | 876 +++++++++++-------
1 file changed, 563 insertions(+), 313 deletions(-)
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 6d127eb70..84a3b9c69 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -27,7 +27,13 @@ import {
CardTitle,
Input,
Label,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
SmartFileInput,
+ Switch,
Textarea,
} from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
@@ -35,22 +41,45 @@ import Breadcrumbs from "@/components/Breadcrumbs";
// ── Mock Constants ─────────────────────────────────────────────────────────────
const STATIONS = [
- "Addis Ababa", "Adama", "Mojo", "Awash", "Mieso",
- "Dire Dawa", "Aysha", "Ali Sabieh", "Holhol", "Djibouti City",
+ "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",
+ "Addis Ababa",
+ "Adama",
+ "Mojo",
+ "Awash",
+ "Mieso",
+ "Dire Dawa",
]);
const BULK_COMMODITIES = [
- "Coffee", "Beans", "Fertilizer", "Sugar", "Oil", "Livestock", "Steel", "Others",
+ "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",
+ "EDR-2024-10001",
+ "EDR-2024-10002",
+ "EDR-2023-88123",
+ "EDR-2022-55442",
];
// US-04 compliance documents (TIN, Business License, Registration, National ID, optional PoA)
@@ -66,11 +95,15 @@ const BOOKING_DOCS_SETTING = {
entity: "booking" as const,
fields: [
{
- id: "f1", createdAt: "", updatedAt: "", deletedAt: null,
+ 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).",
+ helpText:
+ "Tax Identification Number certificate issued by ERCA (10-digit TIN).",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -79,11 +112,15 @@ const BOOKING_DOCS_SETTING = {
order: 1,
},
{
- id: "f2", createdAt: "", updatedAt: "", deletedAt: null,
+ 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.",
+ helpText:
+ "Current business or investment license issued by the relevant government authority.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -92,7 +129,10 @@ const BOOKING_DOCS_SETTING = {
order: 2,
},
{
- id: "f3", createdAt: "", updatedAt: "", deletedAt: null,
+ id: "f3",
+ createdAt: "",
+ updatedAt: "",
+ deletedAt: null,
settingId: "booking-compliance",
fileKey: "business_registration",
fileLabel: "Business Registration Certificate",
@@ -105,11 +145,15 @@ const BOOKING_DOCS_SETTING = {
order: 3,
},
{
- id: "f4", createdAt: "", updatedAt: "", deletedAt: null,
+ 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.",
+ helpText:
+ "Valid government-issued ID or passport of the authorized signatory.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
@@ -118,11 +162,15 @@ const BOOKING_DOCS_SETTING = {
order: 4,
},
{
- id: "f5", createdAt: "", updatedAt: "", deletedAt: null,
+ 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.",
+ helpText:
+ "Required only if a representative is signing on behalf of the company.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
@@ -144,8 +192,8 @@ 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 & Cargo", short: "Cargo" },
- { id: 5, label: "Container Config", short: "Container" },
+ { 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" },
@@ -171,6 +219,7 @@ interface FormData {
equipmentReturn: EquipmentReturn;
originYard: string;
destinationYard: string;
+ cargoType: "container" | "bulk" | "";
cargoWeight: string;
freightType: FreightTypeVal;
bulkCommodity: string;
@@ -200,6 +249,7 @@ const INITIAL_DATA: FormData = {
equipmentReturn: "with_return",
originYard: "",
destinationYard: "",
+ cargoType: "",
cargoWeight: "",
freightType: "",
bulkCommodity: "",
@@ -244,7 +294,8 @@ interface WagonCalcResult {
}
function calcWagons(type: ContainerTypeVal, qty: number): WagonCalcResult {
- if (type === "40ft") return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty };
+ 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 };
@@ -252,29 +303,11 @@ function calcWagons(type: ContainerTypeVal, qty: number): WagonCalcResult {
// ── Shared primitives ──────────────────────────────────────────────────────────
-const selectCls =
- "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs outline-none transition hover:border-ring/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20";
-
-function NativeSelect({
- value, onChange, options, placeholder,
-}: {
- value: string;
- onChange: (v: string) => void;
- options: { value: string; label: string }[];
- placeholder?: string;
-}) {
- return (
- onChange(e.target.value)} className={selectCls}>
- {placeholder && {placeholder} }
- {options.map((o) => (
- {o.label}
- ))}
-
- );
-}
-
function OptionCard({
- selected, onClick, disabled, children,
+ selected,
+ onClick,
+ disabled,
+ children,
}: {
selected: boolean;
onClick?: () => void;
@@ -286,13 +319,12 @@ function OptionCard({
type="button"
onClick={onClick}
disabled={disabled}
- className={`relative w-full rounded-xl border-2 p-4 text-left transition ${
- disabled
+ className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled
? "cursor-not-allowed border-border bg-muted opacity-60"
: selected
- ? "border-primary bg-primary/5"
- : "border-border bg-card hover:border-primary/40"
- }`}
+ ? "border-primary bg-primary/5"
+ : "border-border bg-card hover:border-primary/40"
+ }`}
>
{selected && !disabled && (
@@ -304,27 +336,10 @@ function OptionCard({
);
}
-function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
- return (
- onChange(!enabled)}
- className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
- enabled ? "bg-primary" : "bg-input"
- }`}
- >
-
-
- );
-}
-
-function AlertBox({ tone, children }: {
+function AlertBox({
+ tone,
+ children,
+}: {
tone: "warning" | "error" | "success" | "info";
children: React.ReactNode;
}) {
@@ -341,14 +356,22 @@ function AlertBox({ tone, children }: {
info: ,
};
return (
-
+
);
}
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
return (
{label}
@@ -372,7 +395,11 @@ function StepLabel({ children }: { children: React.ReactNode }) {
// ── Step 1: Contract Type ──────────────────────────────────────────────────────
function Step1({
- d, set, renewalValid, renewalValidating, onValidate,
+ d,
+ set,
+ renewalValid,
+ renewalValidating,
+ onValidate,
}: {
d: FormData;
set: (k: K, v: FormData[K]) => void;
@@ -440,14 +467,18 @@ function Step1({
onClick={onValidate}
disabled={!d.previousContractRef || renewalValidating}
>
- {renewalValidating ? : "Validate"}
+ {renewalValidating ? (
+
+ ) : (
+ "Validate"
+ )}
{renewalValid === true && (
- Contract found. Company details, route, and wagon preferences will
- be pre-filled.
+ Contract found. Company details, route, and wagon
+ preferences will be pre-filled.
)}
{renewalValid === false && (
@@ -465,7 +496,8 @@ function Step1({
// ── Step 2: Service Type ───────────────────────────────────────────────────────
function Step2({
- d, set,
+ d,
+ set,
}: {
d: FormData;
set:
(k: K, v: FormData[K]) => void;
@@ -480,13 +512,17 @@ function Step2({
-
set("serviceType", "rail")}>
+ set("serviceType", "rail")}
+ >
Rail Transport Only
- Rail transport along the EDR corridor, with optional first/last mile trucking.
+ Rail transport along the EDR corridor, with optional first/last mile
+ trucking.
Option A
@@ -502,7 +538,8 @@ function Step2({
Rail Transport & Freight Forwarding
- Rail transport plus documentation, customs liaison, and a dedicated coordinator.
+ Rail transport plus documentation, customs liaison, and a dedicated
+ coordinator.
Option B
@@ -511,8 +548,8 @@ function Step2({
- Customs and Clearance Service cannot be selected independently — it must be bundled with
- a Rail Transport service.
+ Customs and Clearance Service cannot be selected independently — it must
+ be bundled with a Rail Transport service.
);
@@ -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.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" && (
+
+ {
+ set("bulkCommodity", v);
+ if (v !== "Others") set("bulkCommodityOther", "");
+ }}
+ >
+
+
+
+
+ {BULK_COMMODITIES.map((c) => (
+
+ {c}
+
+ ))}
+
+
+ {d.bulkCommodity === "Others" && (
+ set("bulkCommodityOther", e.target.value)}
+ />
+ )}
+
+ )}
+
+ {d.freightType === "break_bulk" && (
+
+ {
+ set("breakBulkType", v);
+ if (v !== "Others") set("breakBulkTypeOther", "");
+ }}
+ >
+
+
+
+
+ {BREAK_BULK_TYPES.map((c) => (
+
+ {c}
+
+ ))}
+
+
+ {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.limit}
+
+ ))}
+
-
-
-
-
set("quantity", String(Math.max(1, qty - 1)))}
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
- >−
-
set("quantity", e.target.value)}
- className="text-center"
- min="1"
- />
-
set("quantity", String(qty + 1))}
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
- >+
+ {direction && (
+
+
+ Route detected as{" "}
+
+ {direction}
+ {" "}
+ workflow
+
+ )}
+
+
-
-
- 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 ? (
-
setStep((s) => s + 1)} disabled={!canProceed()}>
+ setStep((s) => s + 1)}
+ disabled={!canProceed()}
+ >
Continue
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 (
-
- {selected && !disabled && (
-
-
-
- )}
- {children}
-
- );
-}
-
-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 (
-
- {label}
- {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"
- />
-
- {renewalValidating ? (
-
- ) : (
- "Validate"
- )}
-
-
-
- {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
-
-
- set("originYard", v)}
- >
-
-
-
-
- {STATIONS.filter((s) => s !== d.destinationYard).map((s) => (
-
- {s}
-
- ))}
-
-
-
-
- set("destinationYard", v)}
- >
-
-
-
-
- {STATIONS.filter((s) => s !== d.originYard).map((s) => (
-
- {s}
-
- ))}
-
-
-
-
- {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" && (
-
- {
- set("bulkCommodity", v);
- if (v !== "Others") set("bulkCommodityOther", "");
- }}
- >
-
-
-
-
- {BULK_COMMODITIES.map((c) => (
-
- {c}
-
- ))}
-
-
- {d.bulkCommodity === "Others" && (
- set("bulkCommodityOther", e.target.value)}
- />
- )}
-
- )}
-
- {d.freightType === "break_bulk" && (
-
- {
- set("breakBulkType", v);
- if (v !== "Others") set("breakBulkTypeOther", "");
- }}
- >
-
-
-
-
- {BREAK_BULK_TYPES.map((c) => (
-
- {c}
-
- ))}
-
-
- {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.limit}
-
- ))}
-
-
- {direction && (
-
-
- Route detected as{" "}
-
- {direction}
- {" "}
- workflow
-
- )}
-
-
-
-
- {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 || "—"}
-
-
setStep(target)}
- className="shrink-0 text-xs font-medium text-primary hover:underline"
- >
- Edit
-
-
- );
- }
-
- 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}
- />
-
-
-
-
-
-
- Additional Notes
-
-
-
- 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"
- }`}
- >
- {d.termsAccepted && (
-
- )}
-
-
- I confirm the information is accurate and agree to EDR's{" "}
-
- freight contract terms and conditions
-
- .
-
-
-
- );
-}
-
-// ── Main Page ──────────────────────────────────────────────────────────────────
+import {
+ MOCK_VALID_CONTRACTS,
+ STEPS,
+ bookingFormSchema,
+ calcWagons,
+ getRouteDirection,
+ initialBookingFormValues,
+ stepFields,
+ type BookingFormValues,
+} from "./new-booking-form/schema";
+import { StepIndicator } from "./new-booking-form/StepIndicator";
+import {
+ Step1ContractType,
+ Step2ServiceType,
+ Step3FirstLastMile,
+ Step4Route,
+ Step5CargoDetails,
+ Step6WagonAllocation,
+ Step7Documents,
+ Step8Review,
+} from "./new-booking-form/steps";
export default function NewBookingPage() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
- const [data, setData] = useState(INITIAL_DATA);
const [renewalValidating, setRenewalValidating] = useState(false);
const [renewalValid, setRenewalValid] = useState(null);
const [submitted, setSubmitted] = useState(false);
- const direction = useMemo(
- () => getRouteDirection(data.originYard, data.destinationYard),
- [data.originYard, data.destinationYard],
- );
- const wagons = useMemo(() => {
- if (!data.containerType || !data.quantity) return null;
- return calcWagons(data.containerType, parseInt(data.quantity) || 1);
- }, [data.containerType, data.quantity]);
+ const form = useForm({
+ resolver: zodResolver(bookingFormSchema),
+ defaultValues: initialBookingFormValues,
+ mode: "onChange",
+ });
- function set(k: K, v: FormData[K]) {
- setData((prev) => ({ ...prev, [k]: v }));
- }
+ const originYard = form.watch("originYard");
+ const destinationYard = form.watch("destinationYard");
+ const containerType = form.watch("containerType");
+ const quantity = form.watch("quantity");
+ const previousContractRef = form.watch("previousContractRef");
+ const contractId =
+ form.watch("draftContractId") || form.watch("previousContractRef");
+
+ const direction = useMemo(
+ () => getRouteDirection(originYard, destinationYard),
+ [originYard, destinationYard],
+ );
+
+ const wagons = useMemo(() => {
+ if (!containerType || !quantity) return null;
+ return calcWagons(containerType, parseInt(quantity) || 1);
+ }, [containerType, quantity]);
+
+ useEffect(() => {
+ setRenewalValid(null);
+ }, [previousContractRef]);
function validateRenewal() {
+ const previousContractRef = form.getValues("previousContractRef").trim();
+
+ if (!previousContractRef) {
+ form.setError("previousContractRef", {
+ type: "manual",
+ message: "Enter a previous contract reference.",
+ });
+ return;
+ }
+
setRenewalValidating(true);
setRenewalValid(null);
setTimeout(() => {
- setRenewalValidating(false);
- setRenewalValid(
- MOCK_VALID_CONTRACTS.includes(data.previousContractRef.toUpperCase()),
+ const valid = MOCK_VALID_CONTRACTS.includes(
+ previousContractRef.toUpperCase(),
);
+ setRenewalValidating(false);
+ setRenewalValid(valid);
+ if (!valid) {
+ form.setError("previousContractRef", {
+ type: "manual",
+ message: "Contract Reference Number not found or unauthorized.",
+ });
+ } else {
+ form.clearErrors("previousContractRef");
+ }
}, 1200);
}
- function canProceed(): boolean {
- switch (step) {
- case 1:
- if (data.contractType === "new") return !!data.draftContractId;
- if (data.contractType === "renewal") return renewalValid === true;
- return false;
- case 2:
- return !!data.serviceType;
- case 3:
- if (data.firstMileEnabled && !data.pickUpAddress) return false;
- if (data.lastMileEnabled && !data.deliveryAddress) return false;
- return true;
- case 4:
- 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;
- case 7:
- return REQUIRED_DOC_KEYS.every((k) => {
- const f = data.documents[k];
- if (!f) return false;
- return Array.isArray(f) ? f.length > 0 : true;
+ async function handleContinue() {
+ const valid = await form.trigger(stepFields[step], { shouldFocus: true });
+ if (!valid) return;
+
+ if (step === 1 && form.getValues("contractType") === "renewal") {
+ if (renewalValid !== true) {
+ form.setError("previousContractRef", {
+ type: "manual",
+ message:
+ "Validate the previous contract reference before continuing.",
});
- case 8:
- return data.termsAccepted;
- default:
- return true;
+ return;
+ }
}
+
+ setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
- function handleSubmit() {
+ function handleSubmit(data: BookingFormValues) {
+ if (data.contractType === "renewal" && renewalValid !== true) {
+ form.setError("previousContractRef", {
+ type: "manual",
+ message: "Validate the previous contract reference before submitting.",
+ });
+ setStep(1);
+ return;
+ }
+
setSubmitted(true);
setTimeout(() => navigate("/bookings"), 2500);
}
@@ -1561,7 +137,7 @@ export default function NewBookingPage() {
notified once approved.
- {data.draftContractId || data.previousContractRef}
+ {contractId}
@@ -1569,8 +145,11 @@ export default function NewBookingPage() {
}
return (
-
- {/* Sticky step indicator */}
+