diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index bbac17e7a..65272e253 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -17,7 +17,10 @@ import { PositionType, Position, Project, +<<<<<<< HEAD UnitConfiguration, +======= +>>>>>>> 95fb544ec20f01ec4a2d92f546954a5b4e464a4f GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -64,7 +67,10 @@ const iamEntities = [ PositionType, Position, Project, +<<<<<<< HEAD UnitConfiguration, +======= +>>>>>>> 95fb544ec20f01ec4a2d92f546954a5b4e464a4f GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -98,10 +104,8 @@ const iamMigrationsGlob = join( ); const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); -export default registerAs( - "database", - (): TypeOrmModuleOptions => { - return { +export default registerAs("database", (): TypeOrmModuleOptions => { + return { type: "postgres", host: process.env.DB_HOST ?? "localhost", port: parseInt(process.env.DB_PORT ?? "5433", 10), @@ -124,5 +128,4 @@ export default registerAs( synchronize: false, logging: process.env.NODE_ENV === "development", }; - }, -); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 296c60520..48907966a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -35,7 +35,7 @@ export class PaymentController { // } @Post("/bookings/check-payment/:orderId") - checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) { + checkPayment(@Param("orderId") orderId: string) { return this.paymentService.checkStatusAndUpdate(orderId) } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 997a0e18f..549b3db4f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,162 +1,189 @@ -import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from "@nestjs/common"; import { DataSource, QueryRunner } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentStrategy } from "./strategies/payment.strategy"; import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy"; import { PaymentRepository } from "./payment.repository"; import { ClientAction, PaymentPlatform } from "./strategies/payments.types"; -import * as crypto from 'crypto'; +import * as crypto from "crypto"; -import * as fs from 'fs'; -import * as path from 'path'; -import * as Handlebars from 'handlebars'; +import * as fs from "fs"; +import * as path from "path"; +import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; import { Booking } from "../bookings/entities/booking.entity"; - -type PaymentMethod = PaymentEntity["method"] -type CurrencyType = PaymentEntity["currency"] +type PaymentMethod = PaymentEntity["method"]; +type CurrencyType = PaymentEntity["currency"]; @Injectable() export class PaymentService { - private strategies: Map; + private strategies: Map; - constructor( - private readonly configService: ConfigService, - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { - this.strategies = new Map([ - ["telebirr", this.telebirrPaymentStategy as PaymentStrategy] - ]) + constructor( + private readonly configService: ConfigService, + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly telebirrPaymentStategy: PaymentTelebirrStrategy, + ) { + this.strategies = new Map([ + ["telebirr", this.telebirrPaymentStategy as PaymentStrategy], + ]); + } + + async pay( + amount: number, + currency: CurrencyType, + method: PaymentMethod, + reason: string, + type: PaymentEntity["type"], + cb: ( + qr: QueryRunner, + ) => Promise<{ id: string; type: PaymentEntity["type"] }>, + payform: PaymentPlatform = "web", + ): Promise<{ + refId: string; + clientAction: ClientAction; + status: PaymentEntity["status"]; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }> { + const strategy = this.strategies.get(method); + if (!strategy) { + throw new NotFoundException("strategy not found"); } - async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{ - refId: string, - clientAction: ClientAction, - status: PaymentEntity["status"], - paidAt?: string, - failureCode?: string, - failureMessage?: string, - }> { + const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic + let redirectUrl: string; + switch (type) { + case "booking": + const url = this.configService.get( + "TELEBIRR_SUCCESS_REDIRECT_BASE_URL", + ); + redirectUrl = `${url}/${orderId}`; + break; + } + const paymentResp = await strategy.pay({ + redirectUrl, + amountMinor: amount, + currency: currency, + merchantOrderId: orderId, + platform: payform, + }); - const strategy = this.strategies.get(method) - if (!strategy) { - throw new NotFoundException("strategy not found") - } + const queryRunner = this.datasource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); - const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic - let redirectUrl: string; - switch (type) { - case "booking": - const url = this.configService.get("TELEBIRR_SUCCESS_REDIRECT_BASE_URL") - redirectUrl = `${url}/check-status/${orderId}` - break; - } + console.log(paymentResp.expiresAt); + try { + const resp = await cb(queryRunner); + const payment = await this.paymentRepo.createTr(queryRunner, { + amount, + currency, + method, + refId: resp.id, + type: resp.type, + merchantOrderId: orderId, + rawInitiation: paymentResp.rawInitiation, + clientAction: paymentResp.clientAction, + expiresAt: paymentResp.expiresAt, + reason, + }); + await queryRunner.commitTransaction(); + return { + refId: payment.refId, + clientAction: paymentResp.clientAction, + status: payment.status, + paidAt: payment.paidAt?.toISOString(), + failureCode: payment.failerCode ?? undefined, + failureMessage: payment.failureMessage ?? undefined, + }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new Error("payment failed"); + } finally { + await queryRunner.release(); + } + } - const paymentResp = await strategy.pay({ - redirectUrl, - amountMinor: amount, - currency: currency, - merchantOrderId: orderId, - platform: payform, + async getActivePaymentByRefIdAndMethod( + refId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) { + throw new BadRequestException(); + } + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) { + throw new InternalServerErrorException(); + } + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + + const html = template({ + vendorName: "Ethio Djibouti Railway Ticket Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment?.method, + subtotal: payment?.amount.toString(), + total: payment?.amount.toString(), + currency: payment?.currency, + reason: payment?.reason, + }); + + return html; + } + + async checkStatusAndUpdate(orderId: string) { + const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }); + if (!resp) { + throw new NotFoundException("order id not found"); + } + + try { + const result = await this.telebirrPaymentStategy.queryStatus( + resp.merchantOrderId, + ); + const bizContent = result.rawResponse.biz_content as { + order_status: string; + }; + + const ordersStatus = bizContent.order_status; + if (ordersStatus == "PAY_SUCCESS") { + await this.datasource.transaction(async (mg) => { + await mg.update(Booking, { id: resp.refId }, { status: "PAID" }); + await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }); }); - - const queryRunner = this.datasource.createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction() - - console.log(paymentResp.expiresAt) - try { - const resp = await cb(queryRunner) - const payment = await this.paymentRepo.createTr(queryRunner, { - amount, - currency, - method, - refId: resp.id, - type: resp.type, - merchantOrderId: orderId, - rawInitiation: paymentResp.rawInitiation, - clientAction: paymentResp.clientAction, - expiresAt: paymentResp.expiresAt, - reason - - }) - await queryRunner.commitTransaction() - return { - refId: payment.refId, - clientAction: paymentResp.clientAction, - status: payment.status, - paidAt: payment.paidAt?.toISOString(), - failureCode: payment.failerCode ?? undefined, - failureMessage: payment.failureMessage ?? undefined, - } - } catch (err) { - await queryRunner.rollbackTransaction() - throw new Error("payment failed") - } finally { - await queryRunner.release() - } - + } + return { + status: result.status, + }; + } catch { + // Telebirr API unavailable — fall back to current DB payment status + const dbStatus = + resp.status === "success" + ? "success" + : resp.status === "failed" + ? "failed" + : "processing"; + return { status: dbStatus }; } - - - async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method) - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ - merchantOrderId: orderId, - status: "success" - }) - if (!payment) { - throw new BadRequestException() - } - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) { - throw new InternalServerErrorException() - } - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - - const html = template({ - vendorName: "Ethio Djibouti Railway Ticket Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment?.method, - subtotal: payment?.amount.toString(), - total: payment?.amount.toString(), - currency: payment?.currency, - reason: payment?.reason - }); - - return html; - } - - async checkStatusAndUpdate(orderId: string) { - const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId }) - if (!resp) { - throw new NotFoundException("order id not found") - } - const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId) - const bizContent = result.rawResponse.biz_content as { - order_status: string; - }; - - const ordersStatus = bizContent.order_status - if (ordersStatus == "PAY_SUCCESS") { - await this.datasource.transaction(async (mg) => { - await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) - await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) - }) - } - return { - status: result.status - } - } - + } } - diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 147668d91..36922cf4a 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,6 +32,8 @@ import MyBookings from "./pages/bookings/MyBookings"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import EditBookingPage from "./pages/bookings/EditBookingPage"; +import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; import { useEffect } from "react"; @@ -51,7 +53,7 @@ const App = () => { const { user, isPending, logout, customer, customerQuery } = useAuth(); useEffect(() => { - if (isPending) return; + if (isPending || customerQuery.isPending) return; const isInProtectedRoutes = sidebarItems.find((item) => location.pathname.startsWith(item.href), ); @@ -63,7 +65,6 @@ const App = () => { if (user && location.pathname === "/") navigate("/portal"); else if (!customer && !!isInProtectedRoutes) navigate("/onboarding"); - else if (customer && !isInProtectedRoutes) return navigate("/portal"); }, [user, location, customer]); if (isPending) { @@ -86,6 +87,10 @@ const App = () => { } /> } /> } /> + } + /> { } /> } /> } /> + } /> } /> - } /> + } + /> } /> } /> } /> } /> - } /> + {/* } /> */} ); }; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 03123b672..578a1d97d 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -10,7 +10,7 @@ export const URL_CONSTANTS = { USERS: { BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, - SIGN_UP: "/api/auth/signup", + SIGN_UP: "/api/auth/signup-with-pwd", SET_PASSWORD: "/api/auth/set-password", ME: "/api/auth/me", GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index faec1b5b2..2083fcd37 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -1,41 +1,20 @@ -import { useState, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; import { Building2, User, Briefcase, UserCheck, FileCheck, - Loader2, - Save, - UploadCloud, - CheckCircle2, - XCircle, } from "lucide-react"; import { api } from "@/services/api"; -import { companiesService } from "@/services/companies.service"; -import PhoneInput from "@/components/auth/PhoneInput"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, - SmartFileInput, - Badge, -} from "@edr/ui-common"; +import { Badge } from "@edr/ui-common"; import { cn } from "@/lib/utils"; +import TabCompanyProfile from "./settings/TabCompanyProfile"; +import TabContactPerson from "./settings/TabContactPerson"; +import TabGeneralManager from "./settings/TabGeneralManager"; +import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; +import TabDocuments from "./settings/TabDocuments"; type SettingsTab = | "company" @@ -44,32 +23,6 @@ type SettingsTab = | "poa" | "documents"; -const settingsSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), - companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), - generalManagerName: z.string().min(1, "GM name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), - poaName: z.string().optional(), - poaEmail: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), - poaLocation: z.string().optional(), - poaAddress: z.string().optional(), -}); - -type FormData = z.infer; - const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ { id: "company", label: "Company Profile", icon: }, { id: "contact", label: "Contact Person", icon: }, @@ -78,15 +31,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ { id: "documents", label: "Documents", icon: }, ]; -function splitPhone(fullPhone?: string | null): { code: string; number: string } { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - export default function SettingsPage() { - const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; const setTab = (t: SettingsTab) => { @@ -96,130 +41,13 @@ export default function SettingsPage() { return next; }, { replace: true }); }; - const [documentFiles, setDocumentFiles] = useState< - Record - >({}); const profileQuery = useQuery( api.companies.getProfile.queryOptions(), ); - const docSettingQuery = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: "customer_documents" }, - enabled: tab === "documents", - }), - ); - const profile = profileQuery.data; - const defaultValues = useMemo((): FormData => { - if (!profile) { - return { - companyName: "", - companyEmail: "", - companyPhone: "", - companyPhoneCountryCode: "+251", - companyLocation: "", - companyAddress: "", - tinNumber: "", - fanNumber: "", - contactPersonName: "", - contactPersonPhone: "", - contactPersonPhoneCountryCode: "+251", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", - generalManagerPhoneCountryCode: "+251", - poaName: "", - poaEmail: "", - poaPhone: "", - poaPhoneCountryCode: "+251", - poaLocation: "", - poaAddress: "", - }; - } - const contactPhone = splitPhone(profile.contactPersonPhone); - const gmPhone = splitPhone(profile.generalManagerPhone); - const poaPhone = splitPhone(profile.poaPhone); - return { - companyName: profile.companyName, - companyEmail: profile.companyEmail ?? "", - companyPhone: profile.companyPhone ?? "", - companyPhoneCountryCode: splitPhone(profile.companyPhone).code, - companyLocation: profile.companyLocation, - companyAddress: profile.companyAddress ?? "", - tinNumber: profile.tinNumber, - fanNumber: profile.fanNumber ?? "", - contactPersonName: profile.contactPersonName ?? "", - contactPersonPhone: contactPhone.number, - contactPersonPhoneCountryCode: contactPhone.code, - generalManagerName: profile.generalManagerName ?? "", - generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: gmPhone.number, - generalManagerPhoneCountryCode: gmPhone.code, - poaName: profile.poaName ?? "", - poaEmail: profile.poaEmail ?? "", - poaPhone: poaPhone.number, - poaPhoneCountryCode: poaPhone.code, - poaLocation: profile.poaLocation ?? "", - poaAddress: profile.poaAddress ?? "", - }; - }, [profile]); - - const { - register, - handleSubmit, - reset, - formState: { errors, isDirty }, - } = useForm({ - resolver: zodResolver(settingsSchema), - values: defaultValues, - }); - - const updateMutation = useMutation({ - mutationFn: (data: FormData) => - api.companies.updateProfile.call({ - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - fanNumber: data.fanNumber, - contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, - poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - poaAddress: data.poaAddress || undefined, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }); - }, - }); - - const docUploadMutation = useMutation({ - mutationFn: (files: Record) => - companiesService.uploadDocuments(profile!.companyId, files), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }); - }, - }); - - const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending; - if (profileQuery.isPending) { return (
@@ -236,10 +64,6 @@ export default function SettingsPage() { ); } - const onSubmit = (data: FormData) => { - updateMutation.mutate(data); - }; - return (
@@ -276,342 +100,11 @@ export default function SettingsPage() { ))}
-
- - - - {tab === "company" && <> Company Profile} - {tab === "contact" && <> Contact Person} - {tab === "gm" && <> General Manager} - {tab === "poa" && <> Power of Attorney} - {tab === "documents" && <> Documents} - - - {tab === "company" && "Edit your company registration details"} - {tab === "contact" && "Manage the primary contact person for your account"} - {tab === "gm" && "Manage the general manager information"} - {tab === "poa" && "Power of Attorney details are optional"} - {tab === "documents" && "Upload and manage required business documents"} - - - - - - {/* Company Profile Tab */} - {tab === "company" && ( - <> - - Company Name - - - - -
- - Company Email - - - - - -
- -
- - Location - - - - - - Address - - - -
- -
- - TIN Number (10 digits) - - - - - - FAN Number (16 digits) - - - -
- - )} - - {/* Contact Person Tab */} - {tab === "contact" && ( - <> - - Full Name - - - - - - - )} - - {/* General Manager Tab */} - {tab === "gm" && ( - <> - - Full Name - - - - -
- - Email Address - - - - - -
- - )} - - {/* Power of Attorney Tab */} - {tab === "poa" && ( - <> -

- Power of Attorney details are optional. Fill them in if you have - an authorized representative, or leave blank. -

- - - PoA Full Name - - - - -
- - PoA Email - - - - - -
- -
- - PoA Location - - - - - - PoA Address - - - -
- - )} - - {/* Documents Tab */} - {tab === "documents" && ( - <> - {docSettingQuery.isLoading ? ( -
- -
- ) : !docSettingQuery.data ? ( -

- No document requirements configured for your account. -

- ) : ( - - )} - - {docSettingQuery.data && ( -
-
- {docUploadMutation.isSuccess && ( - - - Documents uploaded successfully - - )} - {docUploadMutation.isError && ( - - - Upload failed - - )} -
- -
- )} - - )} -
-
- - {tab !== "documents" && ( - -
- {updateMutation.isSuccess && ( - - - Saved successfully - - )} - {updateMutation.isError && ( - - - Save failed - - )} -
-
- - -
-
- )} -
-
+ {tab === "company" && } + {tab === "contact" && } + {tab === "gm" && } + {tab === "poa" && } + {tab === "documents" && }
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index b5289a5dc..f8c8bc3c9 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -3,12 +3,21 @@ import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { ArrowRight, UserPlus, Loader2 } from "lucide-react"; +import { + ArrowRight, + Eye, + EyeOff, + UserPlus, + Loader2, + Check, + X, +} from "lucide-react"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthLayout from "@/components/auth/AuthLayout"; import PhoneInput from "@/components/auth/PhoneInput"; +import { cn } from "@/lib/utils"; import { Button, Input, @@ -18,23 +27,47 @@ import { FieldGroup, } from "@edr/ui-common"; -const userSchema = z.object({ - email: z.string().email("Invalid email address"), - countryCode: z.string().min(1, "Country code is required"), - phone: z - .string() - .min(9, "Phone number is too short") - .max(9, "Phone number is too long"), - userType: z.string(), - firstName: z.object({ - en: z.string().min(2, "Name is required"), - am: z.string().nullable(), - }), - lastName: z.object({ - en: z.string().min(2, "Name is required"), - am: z.string().nullable(), - }), -}); +const passwordRequirements = [ + { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, + { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, + { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, + { label: "One number", test: (v: string) => /\d/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, +] as const; + +const userSchema = z + .object({ + email: z.string().email("Invalid email address"), + countryCode: z.string().min(1, "Country code is required"), + phone: z + .string() + .min(9, "Phone number is too short") + .max(9, "Phone number is too long"), + userType: z.string(), + firstName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + lastName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a special character"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); type FormData = z.infer; @@ -43,10 +76,13 @@ export default function SignupPage() { const { signup } = useAuth(); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const { register, handleSubmit, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(userSchema), @@ -57,6 +93,8 @@ export default function SignupPage() { userType: userType.individual, firstName: { en: "", am: "" }, lastName: { en: "", am: "" }, + password: "", + confirmPassword: "", }, }); @@ -72,7 +110,12 @@ export default function SignupPage() { username: data.email, phoneNumber: `${data.countryCode}${normalizedPhone}`, userType: data.userType, - name: { en: `${data.firstName.en} ${data.lastName.en}`, am: "" }, + name: { + en: `${data.firstName.en} ${data.lastName.en}`, + am: `${data.firstName.en} ${data.lastName.en}`, + }, + password: data.password, + confirmPassword: data.confirmPassword, }; const result = await signup(payload); if (result.success) { @@ -171,6 +214,81 @@ export default function SignupPage() { countryCodeError={errors.countryCode} phoneError={errors.phone} /> + + + Password +
+ + +
+ +
+ {passwordRequirements.map((req) => { + const met = req.test(watch("password") ?? ""); + return ( +
+ {met ? ( + + ) : ( + + )} + {req.label} +
+ ); + })} +
+
+ + + Confirm Password +
+ + +
+ +
- +
+ + +
@@ -431,20 +441,6 @@ function DraftBookingView({ )} - {pricingQuery.isError && ( -
- -
-

Pricing failed

-

- {pricingQuery.error instanceof Error - ? pricingQuery.error.message - : "An unexpected error occurred."} -

-
-
- )} - {uploadMutation.isError && (
@@ -489,7 +485,7 @@ function DraftBookingView({ @@ -502,28 +498,7 @@ function DraftBookingView({ - {pricingQuery.isLoading ? ( -
- -

- Calculating price… -

-
- ) : pricingQuery.isError ? ( -
-

- Could not calculate price. -

- -
- ) : pricingQuery.data ? ( + {pricing ? (
@@ -536,7 +511,7 @@ function DraftBookingView({ - {pricingQuery.data.lineItems.map((item, i) => ( + {pricing.lineItems.map((item, i) => (
{item.description} @@ -551,29 +526,19 @@ function DraftBookingView({ Total Estimated Cost - {pricingQuery.data.totalAmount.toLocaleString()}{" "} - {pricingQuery.data.currency} + {pricing.totalAmount.toLocaleString()}{" "} + {pricing.currency}
- - {pricingQuery.data.warnings.length > 0 && ( -
- {pricingQuery.data.warnings.map((w, i) => ( -

- - {w} -

- ))} -
- )}
- ) : null} + ) : ( +

+ Pricing will be calculated after submission. +

+ )}
@@ -802,11 +767,28 @@ function DraftBookingView({ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const payMutation = useMutation({ + mutationFn: () => api.bookings.pay.call({ id: booking.id }), + onSuccess: (data) => { + if (data.redirectUrl) { + window.location.href = data.redirectUrl; + } + }, + }); const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; + const pricing = booking.pricingBreakdown; + + const uploadedCodes = useMemo( + () => new Set(booking.files?.map((f) => f.code) ?? []), + [booking.files], + ); + return (
@@ -823,7 +805,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
-
+

{booking.reference} @@ -840,11 +822,100 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {

+ {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( + + )}
- {renderContractCard(booking, navigate)} + {renderContractCard(booking, navigate, payMutation)} + + {pricing && ( + + + + + Pricing Breakdown + + + +
+ + + + + + + + + {pricing.lineItems.map((item, i) => ( + + + + + ))} + + + + + +
DescriptionAmount
{item.description} + {item.amount.toLocaleString()} {item.currency} +
Total Estimated Cost + {pricing.totalAmount.toLocaleString()} {pricing.currency} +
+
+
+
+ )} + + {booking.files && booking.files.length > 0 && ( + + + + + Uploaded Documents ({booking.files.length}) + + + + + + + )} @@ -1200,6 +1271,7 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { function renderContractCard( booking: Freight.IBooking, navigate: ReturnType, + payMutation: { mutate: () => void; isPending: boolean }, ) { const s = booking.status; if ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx new file mode 100644 index 000000000..b72d18ee1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -0,0 +1,1100 @@ +import { useMemo } from "react"; +import { useFieldArray, Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useParams } from "react-router-dom"; +import { + AlertCircle, + Check, + LoaderCircle, + Loader2, + Package, + Weight, + Plus, + Trash2, + MapPin, + Flame, + Snowflake, + Truck, + FileText, +} from "lucide-react"; +import { + Button, + Field, + FieldLabel, + FieldError, + Input, + Badge, + Switch, + Textarea, + Separator, + Skeleton, +} from "@edr/ui-common"; +import type { Freight } from "@edr/types"; +import { api } from "@/services/api"; +import type { CreateBookingPayload } from "@/services/bookings.service"; +import { + BookingFormInputValues, + bookingFormSchema, + getRouteDirection, + initialBookingFormValues, + type BookingFormValues, + type RouteDirection, +} from "./new-booking-form/schema"; +import { + SelectField, + SelectItem, + AlertBox, +} from "./new-booking-form/shared"; + +function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { + return yard?.label ?? yard?.name ?? yard?.code ?? ""; +} + +function mapBookingToFormValues( + booking: Freight.IBooking, + referenceData: Freight.BookingReferenceData, +): BookingFormInputValues { + const vals: BookingFormInputValues = { + ...initialBookingFormValues, + contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", + previousContractRef: booking.previousContractId ?? "", + serviceType: + booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail", + firstMile: { + enabled: booking.firstMileEnabled ?? false, + pickUpAddress: booking.firstMilePickupAddress ?? "", + }, + lastMile: { + enabled: booking.lastMileEnabled ?? false, + deliveryAddress: booking.lastMileDeliveryAddress ?? "", + }, + equipmentReturn: + booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return", + originYard: yardNameFromBooking(booking.originYard), + destinationYard: yardNameFromBooking(booking.destinationYard), + cargoType: booking.freightType === "BULK" ? "bulk" : "container", + cargoWeight: String(booking.cargoTotalWeightVgm ?? ""), + isHazardous: booking.isHazardous ?? false, + isRefrigerated: booking.isRefrigerated ?? false, + shippingLine: (booking as any).shippingLine?.name ?? "", + consolidationEnabled: booking.allowConsolidation ?? false, + notes: "", + termsAccepted: false, + freightType: "", + bulkCommoditytype: "", + containers: [], + }; + + const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined; + if (booking.freightType === "BULK" && bookingCargoTypeId) { + for (const group of referenceData.cargo_type) { + const child = group.children?.find((c) => c.id === bookingCargoTypeId); + if (child) { + vals.freightType = group.code.toLowerCase(); + vals.bulkCommoditytype = child.name; + break; + } + } + } + + if (booking.freightType === "CONTAINER" && booking.containers && booking.containers.length > 0) { + vals.containers = booking.containers.map((c) => ({ + type: c.type === "40ft" ? "40ft" : "20ft" as const, + containerType: "", + qty: String(c.qty), + vgm: String(c.vgm), + })); + } + + return vals; +} + +export default function EditBookingPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const bookingQuery = useQuery( + api.bookings.get.queryOptions({ + input: { id: id! }, + enabled: !!id, + }), + ); + + const { data: referenceData } = useQuery( + api.bookings.referenceData.queryOptions({ + enabled: !!bookingQuery.data, + }), + ); + + const updateMutation = useMutation({ + mutationFn: (payload: Partial) => + api.bookings.update.call({ id: id!, dto: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + navigate(`/bookings/${id}`); + }, + }); + + const booking = bookingQuery.data; + + const formValues = useMemo((): BookingFormInputValues | undefined => { + if (!booking || !referenceData) return undefined; + return mapBookingToFormValues(booking, referenceData); + }, [booking, referenceData]); + + const form = useForm({ + defaultValues: initialBookingFormValues, + values: formValues, + resolver: zodResolver(bookingFormSchema), + mode: "onChange", + }); + + const originYard = form.watch("originYard"); + const destinationYard = form.watch("destinationYard"); + const serviceType = form.watch("serviceType"); + const firstMileEnabled = form.watch("firstMile.enabled"); + const lastMileEnabled = form.watch("lastMile.enabled"); + const cargoType = form.watch("cargoType"); + const freightType = form.watch("freightType"); + const containers = form.watch("containers"); + + const direction: RouteDirection = useMemo( + () => getRouteDirection(originYard, destinationYard), + [originYard, destinationYard], + ); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "containers", + }); + + const yardOptions = useMemo(() => { + if (!referenceData?.yard) return []; + return referenceData.yard.map((y) => ({ + value: y.name, + label: y.name, + country: y.country, + })); + }, [referenceData]); + + const shippingLineOptions = useMemo(() => { + if (!referenceData?.shipping_line) return []; + return referenceData.shipping_line.map((sl) => ({ + value: sl.name, + label: sl.name, + })); + }, [referenceData]); + + const freightTypeGroups = useMemo(() => { + if (!referenceData?.cargo_type) return []; + return referenceData.cargo_type.filter( + (g) => g.code !== "CONTAINER", + ); + }, [referenceData]); + + const commodityOptions = useMemo(() => { + if (!referenceData?.cargo_type || !freightType) return []; + const group = referenceData.cargo_type.find( + (g) => g.code.toLowerCase() === freightType, + ); + return group?.children?.map((c) => c.name) ?? []; + }, [referenceData, freightType]); + + const containerTypeOptions = useMemo(() => { + if (!referenceData?.containers) return []; + return referenceData.containers.flatMap((group) => + group.types.map((t) => t.name), + ); + }, [referenceData]); + + 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 (inside country to outside country)", + import: "Import workflow (outside country to inside country)", + domestic: "Domestic corridor", + }; + + const handleSubmit = form.handleSubmit((data) => { + const yards = referenceData?.yard ?? []; + const services = referenceData?.service ?? []; + const shippingLines = referenceData?.shipping_line ?? []; + const cargoTree = referenceData?.cargo_type ?? []; + const containerGroups = referenceData?.containers ?? []; + + const findYardId = (name: string): string => + yards.find((y) => y.name === name)?.id ?? ""; + + const findServiceTypeId = (): string => { + const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING"; + return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? ""; + }; + + const findShippingLineId = (name: string): string | undefined => + shippingLines.find((l) => l.name === name)?.id; + + const selectedChild = + data.cargoType !== "container" && data.bulkCommoditytype + ? cargoTree + .find((g) => g.code.toLowerCase() === data.freightType) + ?.children?.find((c) => c.name === data.bulkCommoditytype) + : undefined; + + const cargoTypeId = + data.cargoType === "container" + ? undefined + : selectedChild?.id ?? ""; + + const findContainerTypeId = (name: string): string => { + for (const group of containerGroups) { + const ct = group.types.find((t) => t.name === name); + if (ct) return ct.id; + } + return ""; + }; + + const totalWeight = + data.cargoType === "container" + ? data.containers.reduce( + (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ) + : Number(data.cargoWeight || 0); + + const apiPayload: Partial = { + scheduledDate: new Date().toISOString().slice(0, 10), + contractType: + data.contractType.toUpperCase() as CreateBookingPayload["contractType"], + serviceTypeId: findServiceTypeId(), + equipmentReturn: + data.equipmentReturn === "with_return" + ? "WITH_RETURN" + : "WITHOUT_RETURN", + originYardId: findYardId(data.originYard), + destinationYardId: findYardId(data.destinationYard), + tradeDirection: + direction === "export" + ? "EXPORT" + : direction === "domestic" + ? "DOMESTIC" + : "IMPORT", + cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, + cargoTotalWeightVgm: totalWeight, + isHazardous: data.isHazardous, + paymentCurrency: "USD", + allowConsolidation: data.consolidationEnabled, + // @ts-ignore + freightType: + data.cargoType === "container" + ? ("CONTAINER" as const) + : ("BULK" as const), + containers: + data.cargoType === "container" + ? data.containers.map((c) => ({ + containerTypeId: findContainerTypeId(c.containerType), + quantity: Number(c.qty || 1), + vgmPerUnitTons: Number(c.vgm || 0), + })) + : [], + ...(data.previousContractRef + ? { previousContractId: data.previousContractRef } + : {}), + ...(data.contractType === "renewal" && data.previousContractRef + ? { pnrCode: data.previousContractRef } + : {}), + ...(data.serviceType === "rail_forwarding" && data.firstMile.enabled + ? { firstMilePickupAddress: data.firstMile.pickUpAddress } + : {}), + ...(data.serviceType === "rail_forwarding" && data.lastMile.enabled + ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } + : {}), + ...(data.shippingLine + ? { shippingLineId: findShippingLineId(data.shippingLine) } + : {}), + }; + + updateMutation.mutate(apiPayload); + }); + + if (bookingQuery.isLoading) { + return ( +
+ +
+ ); + } + + if (bookingQuery.isError || !booking) { + return ( +
+
+ +

Failed to load booking

+ +
+
+ ); + } + + if (!formValues) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Edit Booking {booking.reference ?? ""} +

+

+ Update the booking details below. All changes are saved together. +

+ + {updateMutation.isError && ( +
+ +
+

Failed to save changes

+

+ {updateMutation.error instanceof Error + ? updateMutation.error.message + : "An unexpected error occurred. Please try again."} +

+
+
+ )} + +
+ {/* ── Section 1: Contract ── */} +
+
+

Contract

+

+ New contract or renewal of an existing one. +

+
+
+ ( + + New Contract + Contract Renewal + + )} + /> + + +
+
+ + + + {/* ── Section 2: Service ── */} +
+
+

Service

+

+ Select the service combination and configure trucking options. +

+
+ +
+ ( + + Rail Transport Only + Logistics (Rail + Forwarding) + + )} + /> + + ( + + With Return + Without Return + + )} + /> +
+ + {serviceType === "rail_forwarding" && ( +
+
+ ( +
+
+ +
+

First Mile - Pick-up

+

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

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("firstMile.pickUpAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {firstMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+ ( +
+
+ +
+

Last Mile - Delivery

+

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

+
+
+ { + field.onChange(value); + if (!value) { + form.setValue("lastMile.deliveryAddress", "", { + shouldDirty: true, + shouldValidate: true, + }); + } + }} + /> +
+ )} + /> + {lastMileEnabled && ( + ( + + + + + )} + /> + )} +
+ +
+ ( +
+
+ +
+

Customs Clearing Service

+

+ EDR handles customs documentation and clearance on your behalf. +

+
+
+ +
+ )} + /> +
+
+ )} +
+ + + + {/* ── Section 3: Route ── */} +
+
+

Route

+

+ Select the origin and destination yards. +

+
+ +
+ ( + + {yardOptions.length === 0 ? ( + No yards available + ) : ( + yardOptions + .filter((y) => y.value !== destinationYard) + .map((y) => ( + + {y.label} + + )) + )} + + )} + /> + + ( + + {yardOptions.length === 0 ? ( + No yards available + ) : ( + yardOptions + .filter((y) => y.value !== originYard) + .map((y) => ( + + {y.label} + + )) + )} + + )} + /> +
+ + {direction && ( +
+ + {directionLabel[direction]} +
+ )} + + {direction && direction !== "domestic" && ( + ( + + {shippingLineOptions.map((sl) => ( + + {sl.label} + + ))} + + )} + /> + )} + +
+
+
+ +
+

Hazardous Material

+

+ Applies a Hazard Surcharge to the final bill. +

+
+
+ ( + + )} + /> +
+
+
+ +
+

Refrigerated Cargo

+

+ Temperature-controlled transport applies a Refrigerator Surcharge. +

+
+
+ ( + + )} + /> +
+
+
+ + + + {/* ── Section 4: Cargo ── */} +
+
+

Cargo Details

+

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

+
+ +
+ ( + + Containerized + General Cargo + + )} + /> + + ( + + + Total Cargo Weight (Tons) * + +
+ + +
+ +
+ )} + /> +
+ + {cargoType === "bulk" && ( + <> +
+ ( + + {freightTypeGroups.map((group) => ( + + {group.name} + + ))} + + )} + /> + + {freightType && commodityOptions.length > 0 && ( + ( + + {commodityOptions.map((option) => ( + + {option} + + ))} + + )} + /> + )} +
+ + ( +
+
+

Allow Consolidation

+

+ Combine shipments to optimize costs. +

+
+ +
+ )} + /> + + )} + + {cargoType === "container" && ( +
+
+

+ Containers +

+ +
+ + {fields.map((field, index) => { + const containerType = containers[index]?.type; + const vgm = containers[index]?.vgm ?? 0; + const alert = (() => { + if (containerType === "20ft" && +vgm > 0) { + const limit = direction === "export" ? 25 : 20; + if (+vgm > limit) { + return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; + } + } + if (containerType === "40ft" && +vgm > 32.5) { + return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`; + } + return null; + })(); + + return ( +
+
+

+ Container {index + 1} +

+ {fields.length > 1 && ( + + )} +
+ +
+ ( + + 20ft (TEU) + 40ft (FEU) + + )} + /> + + ( + + {containerTypeOptions.map((option) => ( + + {option} + + ))} + + )} + /> + + ( + + Quantity * + qtyField.onChange(e.target.value)} + onBlur={qtyField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + min="1" + /> + + + )} + /> + + ( + + VGM (Tons) * + vgmField.onChange(e.target.value)} + onBlur={vgmField.onBlur} + type="number" + aria-invalid={fieldState.invalid} + placeholder="e.g. 18.5" + min="0" + step="0.1" + /> + + + )} + /> +
+ + {alert && ( + + Overweight Alert: {alert} + + )} +
+ ); + })} + + {containers && (() => { + const Ft40Wagons = containers + .filter((c) => c.type === "40ft") + .reduce((sum, c) => sum + Number(c.qty), 0); + const Ft20Wagons = containers + .filter((c) => c.type === "20ft") + .reduce((sum, c) => sum + Number(c.qty), 0); + const hasOddUnit = Ft20Wagons % 2 === 1; + if (hasOddUnit) { + return ( + +
+
+

Unpaired 20ft Container

+

+ One 20ft container occupies only half a wagon. The wagon + will depart once a co-loader is found to fill the + remaining slot, which{" "} + may delay departure beyond the standard + lead time. +

+
+
+
+ ); + } + return null; + })()} +
+ )} +
+ + + + {/* ── Section 5: Notes & Submit ── */} +
+
+

Notes & Confirmation

+

+ Add any special instructions and confirm the changes. +

+
+ + ( + + Additional Notes +