From 4994e19d57dd8a242236c10f18fce3d13d46a02b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 6 Jun 2026 11:12:19 +0300 Subject: [PATCH 1/9] signup update --- .../src/config/database.config.ts | 11 +- .../portal/src/pages/accounts/SignupPage.tsx | 151 +++++++++++++++--- apps/edr-freight-web/portal/src/types/auth.ts | 2 + 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index a511c1654..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -17,7 +17,6 @@ import { PositionType, Position, Project, - UnitSetting, GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -64,7 +63,6 @@ const iamEntities = [ PositionType, Position, Project, - UnitSetting, GlobalUnitConfiguration, Unit, EmployeeSignature, @@ -98,10 +96,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 +120,4 @@ export default registerAs( synchronize: false, logging: process.env.NODE_ENV === "development", }; - }, -); +}); 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..8c305e0ef 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: "", }, }); @@ -73,6 +111,8 @@ export default function SignupPage() { phoneNumber: `${data.countryCode}${normalizedPhone}`, userType: data.userType, name: { en: `${data.firstName.en} ${data.lastName.en}`, am: "" }, + password: data.password, + confirmPassword: data.confirmPassword, }; const result = await signup(payload); if (result.success) { @@ -171,6 +211,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 +
+ + +
+ +
+
+ + +
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..68fd9c70f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -0,0 +1,374 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useNavigate, useParams } from "react-router-dom"; +import { + AlertCircle, + Check, + ChevronLeft, + ChevronRight, + LoaderCircle, + Loader2, +} from "lucide-react"; +import { Button } from "@edr/ui-common"; +import { api } from "@/services/api"; +import type { + CreateBookingPayload, +} from "@/services/bookings.service"; +import { + BookingFormInputValues, + STEPS, + bookingFormSchema, + getRouteDirection, + initialBookingFormValues, + stepFields, + type BookingFormValues, + type RouteDirection, +} from "./new-booking-form/schema"; +import { StepIndicator } from "./new-booking-form/StepIndicator"; +import { + Step1ContractType, + Step2ServiceType, + Step4Route, + Step5CargoDetails, + Step8Review, +} from "./new-booking-form/steps"; +import type { Freight } from "@edr/types"; + +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 [step, setStep] = useState(1); + + const bookingQuery = useQuery( + api.bookings.get.queryOptions({ + input: { id: id! }, + enabled: !!id, + }), + ); + + const { data: referenceData, isLoading: refDataLoading } = 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 direction: RouteDirection = useMemo( + () => getRouteDirection(originYard, destinationYard), + [originYard, destinationYard], + ); + + async function handleContinue() { + const valid = await form.trigger(stepFields[step], { shouldFocus: true }); + if (!valid) return; + setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); + } + + 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 ( +
+
+
+ +
+
+ +
+
+ {updateMutation.isError && ( +
+ +
+

Failed to save changes

+

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

+
+
+ )} + {step === 1 && } + {step === 2 && } + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} +
+
+ +
+
+ + {step < STEPS.length ? ( + + ) : ( + + )} +
+
+
+ ); +} 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 9b5a4cf73..78ba91af6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -138,10 +138,7 @@ export default function NewBookingPage() { const cargoTypeId = data.cargoType === "container" ? findContainerCargoTypeId() - : (findCargoTypeId(data.bulkCommoditytype) ?? - cargoTree.find((g) => g.code.toLowerCase() === data.freightType) - ?.id ?? - ""); + : selectedChild?.id ?? ""; const cargoFreeText = data.cargoType === "container" diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 9b306b8b1..c49ebad0f 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -134,6 +134,11 @@ export const api = { bookingsService.create, ), + update: endpoint< + { id: string; dto: Partial }, + { booking: Freight.IBooking; warnings: string[] } + >("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)), + referenceData: endpoint( "bookings", "referenceData", diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 95a3e5740..d9481d59e 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -74,6 +74,14 @@ export const bookingsService = { const { data } = await client.get("/api/bookings/reference-data"); return data.data; }, + update: async ( + id: string, + payload: Partial, + ): Promise<{ booking: Freight.IBooking; warnings: string[] }> => { + const { data } = await client.patch(`/api/bookings/${id}`, payload); + return data.data; + }, + remove: async (id: string): Promise => { await client.delete(`/api/bookings/${id}`); }, From ea040a40cf215a76179574e4c573b74eda34e524 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 6 Jun 2026 11:21:39 +0300 Subject: [PATCH 3/9] fix: wrong url --- apps/edr-freight-web/portal/src/constants/URLS.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 5898634cd75cfb8a4ee068a545c5bcf1d1b50cac Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 6 Jun 2026 11:29:05 +0300 Subject: [PATCH 4/9] refactor(settings): decompose monolithic SettingsPage into modular tab components --- .../portal/src/pages/SettingsPage.tsx | 531 +----------------- .../src/pages/settings/TabCompanyProfile.tsx | 219 ++++++++ .../src/pages/settings/TabContactPerson.tsx | 145 +++++ .../src/pages/settings/TabDocuments.tsx | 107 ++++ .../src/pages/settings/TabGeneralManager.tsx | 161 ++++++ .../src/pages/settings/TabPowerOfAttorney.tsx | 208 +++++++ 6 files changed, 852 insertions(+), 519 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx 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/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx new file mode 100644 index 000000000..b64419565 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -0,0 +1,219 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Building2, CheckCircle2, Loader2, Save, XCircle } from "lucide-react"; +import { api } from "@/services/api"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; +import type { ProfileResponse } from "@/types/profile"; + +const schema = 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"), +}); + +type FormData = z.infer; + +function splitPhone(fullPhone?: string | null) { + 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 TabCompanyProfile({ profile }: { profile: ProfileResponse }) { + const queryClient = useQueryClient(); + + const defaultValues = useMemo((): FormData => { + const phone = splitPhone(profile.companyPhone); + return { + companyName: profile.companyName, + companyEmail: profile.companyEmail ?? "", + companyPhone: phone.number, + companyPhoneCountryCode: phone.code, + companyLocation: profile.companyLocation, + companyAddress: profile.companyAddress ?? "", + tinNumber: profile.tinNumber, + fanNumber: profile.fanNumber ?? "", + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + }); + + const mutation = 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, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + }, + }); + + const onSubmit = (data: FormData) => mutation.mutate(data); + + return ( + + + + + Company Profile + + Edit your company registration details + +
+ + + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + FAN Number (16 digits) + + + +
+
+
+ +
+ {mutation.isSuccess && ( + + + Saved successfully + + )} + {mutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx new file mode 100644 index 000000000..bb2401be8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -0,0 +1,145 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, Loader2, Save, User, XCircle } from "lucide-react"; +import { api } from "@/services/api"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; +import type { ProfileResponse } from "@/types/profile"; + +const schema = z.object({ + 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"), +}); + +type FormData = z.infer; + +function splitPhone(fullPhone?: string | null) { + 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 TabContactPerson({ profile }: { profile: ProfileResponse }) { + const queryClient = useQueryClient(); + + const defaultValues = useMemo((): FormData => { + const phone = splitPhone(profile.contactPersonPhone); + return { + contactPersonName: profile.contactPersonName ?? "", + contactPersonPhone: phone.number, + contactPersonPhoneCountryCode: phone.code, + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + }, + }); + + const onSubmit = (data: FormData) => mutation.mutate(data); + + return ( + + + + + Contact Person + + Manage the primary contact person for your account + +
+ + + + Full Name + + + + + + + + +
+ {mutation.isSuccess && ( + + + Saved successfully + + )} + {mutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx new file mode 100644 index 000000000..298f7ad7c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CheckCircle2, + FileCheck, + Loader2, + UploadCloud, + XCircle, +} from "lucide-react"; +import { api } from "@/services/api"; +import { companiesService } from "@/services/companies.service"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + SmartFileInput, +} from "@edr/ui-common"; +import type { ProfileResponse } from "@/types/profile"; + +export default function TabDocuments({ profile }: { profile: ProfileResponse }) { + const queryClient = useQueryClient(); + const [documentFiles, setDocumentFiles] = useState>({}); + + const docSettingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: "customer_documents" }, + }), + ); + + const docUploadMutation = useMutation({ + mutationFn: (files: Record) => + companiesService.uploadDocuments(profile.companyId, files), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + }, + }); + + return ( + + + + + Documents + + + Upload and manage required business documents + + + + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( +

+ No document requirements configured for your account. +

+ ) : ( + + )} + + {docSettingQuery.data && ( +
+
+ {docUploadMutation.isSuccess && ( + + + Documents uploaded successfully + + )} + {docUploadMutation.isError && ( + + + Upload failed + + )} +
+ +
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx new file mode 100644 index 000000000..7c7f0d4d3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -0,0 +1,161 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Briefcase, CheckCircle2, Loader2, Save, XCircle } from "lucide-react"; +import { api } from "@/services/api"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; +import type { ProfileResponse } from "@/types/profile"; + +const schema = z.object({ + 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"), +}); + +type FormData = z.infer; + +function splitPhone(fullPhone?: string | null) { + 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 TabGeneralManager({ profile }: { profile: ProfileResponse }) { + const queryClient = useQueryClient(); + + const defaultValues = useMemo((): FormData => { + const phone = splitPhone(profile.generalManagerPhone); + return { + generalManagerName: profile.generalManagerName ?? "", + generalManagerEmail: profile.generalManagerEmail ?? "", + generalManagerPhone: phone.number, + generalManagerPhoneCountryCode: phone.code, + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + values: defaultValues, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); + }, + }); + + const onSubmit = (data: FormData) => mutation.mutate(data); + + return ( + + + + + General Manager + + Manage the general manager information + +
+ + + + Full Name + + + + +
+ + Email Address + + + + + +
+
+
+ +
+ {mutation.isSuccess && ( + + + Saved successfully + + )} + {mutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx new file mode 100644 index 000000000..e5d977e2d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -0,0 +1,208 @@ +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, Loader2, Save, UserCheck, XCircle } from "lucide-react"; +import { api } from "@/services/api"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; +import type { ProfileResponse } from "@/types/profile"; + +const schema = z.object({ + 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; + +function splitPhone(fullPhone?: string | null) { + 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 TabPowerOfAttorney({ + profile, +}: { + profile: ProfileResponse; +}) { + const queryClient = useQueryClient(); + + const defaultValues = useMemo((): FormData => { + const phone = splitPhone(profile.poaPhone); + return { + poaName: profile.poaName ?? "", + poaEmail: profile.poaEmail ?? "", + poaPhone: phone.number, + poaPhoneCountryCode: profile.poaPhone ? phone.code : "", + poaLocation: profile.poaLocation ?? "", + poaAddress: profile.poaAddress ?? "", + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + + values: defaultValues, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + 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 onSubmit = (data: FormData) => mutation.mutate(data); + + return ( + + + + + Power of Attorney + + + Power of Attorney details are optional. Fill them in if you have an + authorized representative, or leave blank. + + +
+ + +

+ 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 + + + +
+
+
+ +
+ {mutation.isSuccess && ( + + + Saved successfully + + )} + {mutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+
+
+ ); +} From efdcb9a7c8f325c71dfddb313bced320e5cdeee8 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 6 Jun 2026 11:29:14 +0300 Subject: [PATCH 5/9] fix: signup am --- .../edr-freight-web/portal/src/pages/accounts/SignupPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 8c305e0ef..f8c8bc3c9 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -110,7 +110,10 @@ 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, }; From c76c6dbf90a8a210e29983e9934d2bcd20b8e69f Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Sat, 6 Jun 2026 11:43:59 +0300 Subject: [PATCH 6/9] feat(bookings): Revamp Edit Booking page to a single-form experience with enhanced details --- .../src/pages/bookings/EditBookingPage.tsx | 908 ++++++++++++++++-- 1 file changed, 817 insertions(+), 91 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx index 68fd9c70f..b72d18ee1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -1,40 +1,51 @@ -import { useMemo, useState } from "react"; +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 { useForm } from "react-hook-form"; import { useNavigate, useParams } from "react-router-dom"; import { AlertCircle, Check, - ChevronLeft, - ChevronRight, LoaderCircle, Loader2, + Package, + Weight, + Plus, + Trash2, + MapPin, + Flame, + Snowflake, + Truck, + FileText, } from "lucide-react"; -import { Button } from "@edr/ui-common"; +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 type { CreateBookingPayload } from "@/services/bookings.service"; import { BookingFormInputValues, - STEPS, bookingFormSchema, getRouteDirection, initialBookingFormValues, - stepFields, type BookingFormValues, type RouteDirection, } from "./new-booking-form/schema"; -import { StepIndicator } from "./new-booking-form/StepIndicator"; import { - Step1ContractType, - Step2ServiceType, - Step4Route, - Step5CargoDetails, - Step8Review, -} from "./new-booking-form/steps"; -import type { Freight } from "@edr/types"; + 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 ?? ""; @@ -103,7 +114,6 @@ export default function EditBookingPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [step, setStep] = useState(1); const bookingQuery = useQuery( api.bookings.get.queryOptions({ @@ -112,7 +122,7 @@ export default function EditBookingPage() { }), ); - const { data: referenceData, isLoading: refDataLoading } = useQuery( + const { data: referenceData } = useQuery( api.bookings.referenceData.queryOptions({ enabled: !!bookingQuery.data, }), @@ -144,17 +154,72 @@ export default function EditBookingPage() { 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], ); - async function handleContinue() { - const valid = await form.trigger(stepFields[step], { shouldFocus: true }); - if (!valid) return; - setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); - } + 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 ?? []; @@ -290,83 +355,744 @@ export default function EditBookingPage() { 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."} -

+ {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. +

+
+
+ ( + + )} + />
- )} - {step === 1 && } - {step === 2 && } - {step === 3 && ( - - )} - {step === 4 && ( - - )} - {step === 5 && ( - - )} -
-
+ -
-
+ + + {/* ── 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 +