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/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx
new file mode 100644
index 000000000..2003457f0
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx
@@ -0,0 +1,99 @@
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "@/lib/utils";
+import { api } from "@/services/api";
+import { Loader2, AlertCircle } from "lucide-react";
+import * as React from "react";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@edr/ui-common";
+
+export interface DynamicSelectProps {
+ code: string;
+ placeholder?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+export function DynamicSelect({
+ code,
+ placeholder,
+ value,
+ onValueChange,
+ disabled,
+ className,
+}: DynamicSelectProps) {
+ const { data, isLoading, isError } = useQuery(
+ api.dropdownSettings.getByCode.queryOptions({ input: { code } }),
+ );
+
+ if (isLoading) {
+ return (
+
+
+ Loading...
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
+ Failed to load options
+
+ );
+ }
+
+ const options = [...data.children].sort((a, b) => a.order - b.order);
+
+ return (
+
+
+
+
+
+ {options.length === 0 ? (
+
+ No options available
+
+ ) : (
+ options.map((option) => (
+
+ {option.label}
+ {option.note ? (
+
+ {option.note}
+
+ ) : null}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts b/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts
new file mode 100644
index 000000000..4e6c58b62
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts
@@ -0,0 +1,2 @@
+export { DynamicSelect } from "./DynamicSelect";
+export type { DynamicSelectProps } from "./DynamicSelect";
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
-
+
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..e23b44cb2 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -1,1301 +1,124 @@
-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,
- SmartFileInput,
- 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 & Cargo", short: "Cargo" },
- { id: 5, label: "Container Config", short: "Container" },
- { 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;
- 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: "",
- 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 ──────────────────────────────────────────────────────────
-
-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: boolean;
- onClick?: () => void;
- disabled?: boolean;
- children: React.ReactNode;
-}) {
- return (
-
- {selected && !disabled && (
-
-
-
- )}
- {children}
-
- );
-}
-
-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 }: {
- 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 & Cargo
-
- Define the route, weight, and cargo classification.
-
-
-
- {/* Route */}
-
-
Route
-
-
- set("originYard", v)}
- options={STATIONS.filter((s) => s !== d.destinationYard).map((s) => ({
- value: s, label: s,
- }))}
- placeholder="Select origin..."
- />
-
-
- set("destinationYard", v)}
- options={STATIONS.filter((s) => s !== d.originYard).map((s) => ({
- value: s, label: s,
- }))}
- placeholder="Select destination..."
- />
-
-
- {direction && (
-
-
- {directionLabel[direction]}
-
- )}
-
-
-
-
- {/* 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 */}
-
-
-
-
-
-
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 (
-
-
-
Container Configuration
-
- Select container type, quantity, and Verified Gross Mass per container.
-
-
-
-
- {([
- {
- 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 containers = form.watch("containers");
+ 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 (!containers || containers.length === 0) return null;
+ return calcWagons(containers);
+ }, [containers]);
+
+ 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(() => {
+ const valid = MOCK_VALID_CONTRACTS.includes(
+ previousContractRef.toUpperCase(),
+ );
setRenewalValidating(false);
- setRenewalValid(MOCK_VALID_CONTRACTS.includes(data.previousContractRef.toUpperCase()));
+ 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 || !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;
- }
- return true;
- case 5:
- 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);
}
@@ -1309,11 +132,11 @@ 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}
+ {contractId}
@@ -1321,71 +144,44 @@ export default function NewBookingPage() {
}
return (
-
- {/* Sticky step indicator */}
+