diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 84a3b9c69..2fe104665 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,1549 +1,125 @@ -import { Fragment, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { - AlertTriangle, - Check, - CheckCircle2, - ChevronLeft, - ChevronRight, - FileText, - Flame, - Info, - Loader2, - MapPin, - Package, - RefreshCw, - Snowflake, - Train, - Weight, - XCircle, -} from "lucide-react"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SmartFileInput, - Switch, - Textarea, -} from "@edr/ui-common"; +import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; - -// ── Mock Constants ───────────────────────────────────────────────────────────── - -const STATIONS = [ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", - "Aysha", - "Ali Sabieh", - "Holhol", - "Djibouti City", -]; - -const ETHIOPIA_STATIONS = new Set([ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", -]); - -const BULK_COMMODITIES = [ - "Coffee", - "Beans", - "Fertilizer", - "Sugar", - "Oil", - "Livestock", - "Steel", - "Others", -]; - -const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"]; - -const MOCK_VALID_CONTRACTS = [ - "EDR-2024-10001", - "EDR-2024-10002", - "EDR-2023-88123", - "EDR-2022-55442", -]; - -// US-04 compliance documents (TIN, Business License, Registration, National ID, optional PoA) -const BOOKING_DOCS_SETTING = { - id: "booking-compliance", - createdAt: "", - updatedAt: "", - deletedAt: null, - code: "booking_compliance_docs", - label: "Compliance Documents", - description: - "Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.", - entity: "booking" as const, - fields: [ - { - id: "f1", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: - "Tax Identification Number certificate issued by ERCA (10-digit TIN).", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 1, - }, - { - id: "f2", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_license", - fileLabel: "Business / Investment License", - helpText: - "Current business or investment license issued by the relevant government authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 2, - }, - { - id: "f3", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_registration", - fileLabel: "Business Registration Certificate", - helpText: "Certificate of registration from the relevant authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 3, - }, - { - id: "f4", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "national_id", - fileLabel: "National ID / Passport", - helpText: - "Valid government-issued ID or passport of the authorized signatory.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 4, - }, - { - id: "f5", - createdAt: "", - updatedAt: "", - deletedAt: null, - settingId: "booking-compliance", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney (PoA)", - helpText: - "Required only if a representative is signing on behalf of the company.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 5, - }, - ], -}; - -const REQUIRED_DOC_KEYS = [ - "tin_certificate", - "business_license", - "business_registration", - "national_id", -]; - -const STEPS = [ - { id: 1, label: "Contract Type", short: "Contract" }, - { id: 2, label: "Service Type", short: "Service" }, - { id: 3, label: "First & Last Mile", short: "Mile" }, - { id: 4, label: "Route", short: "Route" }, - { id: 5, label: "Cargo Details", short: "Cargo" }, - { id: 6, label: "Wagon Allocation", short: "Wagons" }, - { id: 7, label: "Documents", short: "Docs" }, - { id: 8, label: "Review & Submit", short: "Submit" }, -] as const; - -// ── Types ────────────────────────────────────────────────────────────────────── - -type ContractTypeVal = "new" | "renewal" | ""; -type ServiceTypeVal = "rail" | "rail_forwarding" | ""; -type FreightTypeVal = "bulk" | "break_bulk" | ""; -type ContainerTypeVal = "20ft" | "40ft" | ""; -type EquipmentReturn = "with_return" | "without_return"; - -interface FormData { - contractType: ContractTypeVal; - previousContractRef: string; - draftContractId: string; - serviceType: ServiceTypeVal; - firstMileEnabled: boolean; - pickUpAddress: string; - lastMileEnabled: boolean; - deliveryAddress: string; - equipmentReturn: EquipmentReturn; - originYard: string; - destinationYard: string; - cargoType: "container" | "bulk" | ""; - cargoWeight: string; - freightType: FreightTypeVal; - bulkCommodity: string; - bulkCommodityOther: string; - breakBulkType: string; - breakBulkTypeOther: string; - isHazardous: boolean; - isRefrigerated: boolean; - containerType: ContainerTypeVal; - quantity: string; - vgm: string; - consolidationEnabled: boolean; - documents: Record; - notes: string; - termsAccepted: boolean; -} - -const INITIAL_DATA: FormData = { - contractType: "", - previousContractRef: "", - draftContractId: "", - serviceType: "", - firstMileEnabled: false, - pickUpAddress: "", - lastMileEnabled: false, - deliveryAddress: "", - equipmentReturn: "with_return", - originYard: "", - destinationYard: "", - cargoType: "", - cargoWeight: "", - freightType: "", - bulkCommodity: "", - bulkCommodityOther: "", - breakBulkType: "", - breakBulkTypeOther: "", - isHazardous: false, - isRefrigerated: false, - containerType: "", - quantity: "1", - vgm: "", - consolidationEnabled: false, - documents: {}, - notes: "", - termsAccepted: false, -}; - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -function genContractId(): string { - const yr = new Date().getFullYear(); - const n = Math.floor(10000 + Math.random() * 90000); - return `EDR-DRAFT-${yr}-${n}`; -} - -type RouteDirection = "import" | "export" | "domestic" | null; - -function getRouteDirection(origin: string, dest: string): RouteDirection { - if (!origin || !dest) return null; - const oEth = ETHIOPIA_STATIONS.has(origin); - const dEth = ETHIOPIA_STATIONS.has(dest); - if (oEth && !dEth) return "export"; - if (!oEth && dEth) return "import"; - if (oEth && dEth) return "domestic"; - return null; -} - -interface WagonCalcResult { - totalWagons: number; - hasOddUnit: boolean; - sharedWagons: number; -} - -function calcWagons(type: ContainerTypeVal, qty: number): WagonCalcResult { - if (type === "40ft") - return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty }; - const pairs = Math.floor(qty / 2); - const odd = qty % 2; - return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs }; -} - -// ── Shared primitives ────────────────────────────────────────────────────────── - -function OptionCard({ - selected, - onClick, - disabled, - children, -}: { - selected: boolean; - onClick?: () => void; - disabled?: boolean; - children: React.ReactNode; -}) { - return ( - - ); -} - -function AlertBox({ - tone, - children, -}: { - tone: "warning" | "error" | "success" | "info"; - children: React.ReactNode; -}) { - const s = { - warning: "bg-amber-50 border-amber-200 text-amber-800", - error: "bg-red-50 border-red-200 text-red-800", - success: "bg-emerald-50 border-emerald-200 text-emerald-800", - info: "bg-sky-50 border-sky-200 text-sky-800", - }; - const icons = { - warning: , - error: , - success: , - info: , - }; - return ( -
- {icons[tone]} -
{children}
-
- ); -} - -function Field({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} - -function Divider() { - return
; -} - -function StepLabel({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

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

Contract Type

-

- New contract or renewal of an existing one. -

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

New Contract

-

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

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

- {d.draftContractId} -

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

Contract Renewal

-

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

-
-
- - {d.contractType === "renewal" && ( -
- -
- set("previousContractRef", e.target.value)} - className="font-mono" - /> - -
-
- {renewalValid === true && ( - - Contract found. Company details, route, and wagon - preferences will be pre-filled. - - )} - {renewalValid === false && ( - - Contract Reference Number not found or unauthorized. Try{" "} - EDR-2024-10001. - - )} -
- )} -
- ); -} - -// ── Step 2: Service Type ─────────────────────────────────────────────────────── - -function Step2({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - return ( -
-
-

Service Type

-

- Select the service combination you require. -

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

Rail Transport Only

-

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

- - Option A - -
- - set("serviceType", "rail_forwarding")} - > -
- -
-

Rail Transport & Freight Forwarding

-

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

- - Option B - -
-
- -

- Customs and Clearance Service cannot be selected independently — it must - be bundled with a Rail Transport service. -

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

First & Last Mile

-

- Configure trucking and container return options. -

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

First Mile – Pick-up

-

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

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

Last Mile – Delivery

-

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

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

Equipment Return

-

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

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

With Return

-

- Container returned to EDR after unloading. -

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

Without Return

-

- Container retained by the customer after delivery. -

-
-
-
-
-
- ); -} - -// ── Step 4: Route & Cargo ────────────────────────────────────────────────────── - -function Step4({ - d, - set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - const direction = getRouteDirection(d.originYard, d.destinationYard); - const directionStyle: Record = { - export: "bg-sky-50 text-sky-800 border-sky-200", - import: "bg-amber-50 text-amber-800 border-amber-200", - domestic: "bg-muted text-muted-foreground border-border", - }; - const directionLabel: Record = { - export: "Export workflow (Ethiopia → Djibouti)", - import: "Import workflow (Djibouti → Ethiopia)", - domestic: "Domestic corridor", - }; - - return ( -
-
-

Route

-

- Select the origin and destination yards. -

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

Hazardous Material

-

- Applies a Hazard Surcharge to the final bill. -

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

Refrigerated Cargo

-

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

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

Cargo Details

-

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

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

Container

-

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

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

Bulk

-

- Bulk commodities or break-bulk cargo. -

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

Bulk

-

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

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

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
-
- - {d.freightType === "bulk" && ( -
- - {d.bulkCommodity === "Others" && ( - set("bulkCommodityOther", e.target.value)} - /> - )} -
- )} - - {d.freightType === "break_bulk" && ( -
- - {d.breakBulkType === "Others" && ( - set("breakBulkTypeOther", e.target.value)} - /> - )} -
- )} -
- - - - {/* Weight */} -
- Weight - -
- - set("cargoWeight", e.target.value)} - className="pl-9" - min="0" - step="0.01" - /> -
-
-
- - )} - - {/* Container section */} - {d.cargoType === "container" && ( - <> - -
- Container Configuration -
- {[ - { - val: "20ft" as const, - label: "20ft Container (TEU)", - limit: - direction === "export" - ? "Max 25t per container" - : "Max 20t per container", - }, - { - val: "40ft" as const, - label: "40ft Container (FEU)", - limit: "Max 32.5t per container", - }, - ].map((ct) => ( - set("containerType", ct.val)} - > -
- -

{ct.label}

-
-

{ct.limit}

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

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

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

Wagon Allocation

-

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

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

- {wagons.totalWagons} -

-

- Wagons Required -

-
-
-

{qty}

-

- {d.containerType} Containers -

-
-
-

{wagons.sharedWagons}

-

Shared Slots

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

- Formula:{" "} - {d.containerType === "40ft" - ? "1 × 40ft = 1 Rail Wagon" - : `CEILING(${qty} ÷ 2) = ${wagons.totalWagons} Rail Wagon${wagons.totalWagons > 1 ? "s" : ""} — (2 × 20ft = 1 Wagon)`} -

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

- Consolidation Option (US-07) -

-
-

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

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

Allow Consolidation

-

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

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

Dedicated Wagon

-

- Exclusive slot — standard single-party billing. -

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

- Compliance Documents -

-

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

-
- -
-
- {uploadedRequired}/{REQUIRED_DOC_KEYS.length} -
-

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

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

{label}

-

{value || "—"}

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

Review & Submit

-

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

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