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 34ea2106a..6d127eb70 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,366 +1,1426 @@ -import { useState, type ReactNode } from "react"; -import { ArrowRight, Calendar, MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; - +import { Fragment, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; + 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 Breadcrumbs from "@/components/Breadcrumbs"; -import { customers } from "../customers/customers.mock"; -import type { - CargoType, - ContainerType, - LegMode, - Priority, - TransportLeg, - TransportMode, -} from "./bookings.mock"; +// ── Mock Constants ───────────────────────────────────────────────────────────── -export interface BookingFormData { - customerId?: number; - cargoType?: CargoType; - originStation?: string; - destinationStation?: string; - transportMode?: TransportMode; - legs?: TransportLeg[]; - containerType?: ContainerType; - containerCount?: number; - weightTons?: number; - requestedDate?: string; - priority?: Priority; - cargoDescription?: string; - specialInstructions?: string; +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; } -export interface NewBookingPageProps { - mode?: "create" | "edit"; - booking?: BookingFormData; - children?: ReactNode; +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}`; } -const selectClass = - "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"; +type RouteDirection = "import" | "export" | "domestic" | null; -const emptyLeg: TransportLeg = { mode: "Rail", from: "", to: "" }; +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; +} -export default function NewBookingPage({ - mode = "create", - booking, - children, -}: NewBookingPageProps = {}) { - const isEdit = mode === "edit"; - const title = isEdit ? "Edit Freight Booking" : "New Freight Booking"; - const description = isEdit - ? "Update an existing freight booking." - : "Create a freight booking with cargo and route details."; - const submitLabel = isEdit ? "Save Changes" : "Submit Booking"; +interface WagonCalcResult { + totalWagons: number; + hasOddUnit: boolean; + sharedWagons: number; +} - const [transportMode, setTransportMode] = useState( - booking?.transportMode ?? "Rail", +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 ( + ); - const [legs, setLegs] = useState( - booking?.legs && booking.legs.length > 0 - ? booking.legs - : [{ ...emptyLeg }], +} + +function OptionCard({ + selected, onClick, disabled, children, +}: { + selected: boolean; + onClick?: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + ); +} - const addLeg = () => - setLegs((prev) => [...prev, { ...emptyLeg }]); +function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} - const removeLeg = (index: number) => - setLegs((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); +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}
+
+ ); +} - const updateLeg = (index: number, patch: Partial) => - setLegs((prev) => - prev.map((leg, i) => (i === index ? { ...leg, ...patch } : leg)), - ); +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 ( - - - {children ?? } - +
+
+

Route & Cargo

+

+ Define the route, weight, and cargo classification. +

+
- - - {title} - {description} - - -
- {/* Customer */} -
- - + {/* 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]}
+ )} +
- {/* Cargo Type */} -
- - -
+ - {/* Origin Station */} -
- -
- - -
-
- - {/* Destination Station */} -
- -
- - -
-
- - {/* Transport Mode (controlled) */} -
- - -
- - {/* Container Type */} -
- - -
- - {/* Multimodal Transport Legs */} - {transportMode === "Multimodal" ? ( -
-
-
-
-

- Transport Legs -

-

- Define each segment of the multimodal journey. -

-
- -
- -
- {legs.map((leg, i) => ( -
-
- - Leg {i + 1} - - {legs.length > 1 ? ( - - ) : null} -
- -
-
- - -
- -
- - - updateLeg(i, { from: e.target.value }) - } - placeholder="Start station" - /> -
- -
- -
- - - updateLeg(i, { to: e.target.value }) - } - placeholder="End station" - className="pl-10" - /> -
-
-
-
- ))} -
-
-
- ) : null} - - {/* Container Count */} -
- -
- - -
-
- - {/* Weight */} -
- -
- - -
-
- - {/* Requested Date */} -
- -
- - -
-
- - {/* Priority */} -
- - -
- - {/* Cargo Description */} -
- -