diff --git a/apps/edr-freight-web/portal/index.css b/apps/edr-freight-web/portal/index.css index 4909e33bd..aa14f97fe 100644 --- a/apps/edr-freight-web/portal/index.css +++ b/apps/edr-freight-web/portal/index.css @@ -1,2 +1,33 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); + +/* Bridge the central Mantine theme into Tailwind. Mantine (createTheme) is the + single source of truth; these just alias its generated CSS variables so + `bg-edr-*`, `text-edr-*`, `border-edr-*` utilities resolve to the same tokens. */ +@theme { + --color-edr-primary: var(--mantine-color-edr-green-5); + --color-edr-primary-dark: var(--mantine-color-edr-green-7); + --color-edr-bg: var(--mantine-color-edr-bg-6); + --color-edr-card: var(--mantine-color-edr-card-6); + --color-edr-border: var(--mantine-color-edr-border-6); + --color-edr-divider: var(--mantine-color-edr-divider-6); + --color-edr-text: var(--mantine-color-edr-text-6); + --color-edr-muted: var(--mantine-color-edr-muted-6); + --color-edr-soft: var(--mantine-color-edr-soft-6); + --color-edr-ink: var(--mantine-color-edr-ink-6); + --color-edr-accent: var(--mantine-color-edr-accent-6); + + --color-edr-amber-soft: var(--mantine-color-edr-amber-soft-6); + --color-edr-amber-text: var(--mantine-color-edr-amber-text-6); + --color-edr-blue: var(--mantine-color-edr-blue-6); + --color-edr-blue-soft: var(--mantine-color-edr-blue-soft-6); + --color-edr-blue-dot: var(--mantine-color-edr-blue-dot-6); + --color-edr-red: var(--mantine-color-edr-red-6); + --color-edr-red-soft: var(--mantine-color-edr-red-soft-6); + --color-edr-slate: var(--mantine-color-edr-slate-6); + --color-edr-slate-soft: var(--mantine-color-edr-slate-soft-6); + --color-edr-slate-soft2: var(--mantine-color-edr-slate-soft2-6); + --color-edr-step: var(--mantine-color-edr-step-6); + --color-edr-step-idle: var(--mantine-color-edr-step-idle-6); + --color-edr-conn-idle: var(--mantine-color-edr-conn-idle-6); +} diff --git a/apps/edr-freight-web/portal/index.html b/apps/edr-freight-web/portal/index.html index 2233dc861..42bfe97d3 100644 --- a/apps/edr-freight-web/portal/index.html +++ b/apps/edr-freight-web/portal/index.html @@ -5,6 +5,12 @@ EDR Freight Portal + + + diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 582a2fc0b..ae79d7562 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -15,6 +15,8 @@ "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@hookform/resolvers": "^5.4.0", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui-common": "1.1.2", "axios": "^1.7.7", diff --git a/apps/edr-freight-web/portal/public/assets/edr-logo.png b/apps/edr-freight-web/portal/public/assets/edr-logo.png new file mode 100644 index 000000000..c877cfa02 Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/edr-logo.png differ diff --git a/apps/edr-freight-web/portal/public/assets/train-edr.jpg b/apps/edr-freight-web/portal/public/assets/train-edr.jpg new file mode 100644 index 000000000..4595b3bcb Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/train-edr.jpg differ diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 36922cf4a..ddccd2f4c 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,128 +1,214 @@ -import { - useNavigate, - useLocation, - Routes, - Route, - Navigate, - Outlet, -} from "react-router-dom"; -import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; +import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, - MapPin, - Receipt, Home, Loader2, - User, + MapPin, + Receipt, Settings, + User, } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { + Navigate, + Outlet, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router-dom"; import useAuth from "./hooks/useAuth"; - +import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; +import MyPortalPage from "./pages/MyPortalPage"; import ProfilePage from "./pages/ProfilePage"; import SettingsPage from "./pages/SettingsPage"; -import MyPortalPage from "./pages/MyPortalPage"; -import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; -import SignupPage from "./pages/accounts/SignupPage"; -import OnboardingPage from "./pages/accounts/OnboardingPage"; -import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; -import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import LoginPage from "./pages/accounts/LoginPage"; -import MyBookings from "./pages/bookings/MyBookings"; +import OnboardingPage from "./pages/accounts/OnboardingPage"; +import SetPasswordPage from "./pages/accounts/SetPasswordPage"; +import SignupPage from "./pages/accounts/SignupPage"; +import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; +import BillingPage from "./pages/billing/BillingPage"; 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 MyBookings from "./pages/bookings/MyBookings"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import TrackingPage from "./pages/tracking/TrackingPage"; -import BillingPage from "./pages/billing/BillingPage"; -import { useEffect } from "react"; + +function FullScreenSpinner() { + return ( +
+ +
+ ); +} + +function LogoutHandler() { + const { logout } = useAuth(); + const navigate = useNavigate(); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + logout().then(() => navigate("/login", { replace: true })); + }, []); + + return ; +} + +/** Blocks unauthenticated users; renders children only with a valid session. */ +function RequireAuth() { + const { isPending, isAuthenticated } = useAuth(); + const location = useLocation(); + + if (isPending) return ; + if (!isAuthenticated) + return ; + return ; +} + +/** + * Sends authenticated users without a company to onboarding. + * Only redirects on a confirmed "no company" response — never on a + * transient query error. + */ +function RequireCompany() { + const { customerQuery } = useAuth(); + + if (customerQuery.isPending) return ; + if (customerQuery.isSuccess && !customerQuery.data) + return ; + return ; +} + +/** Keeps already-onboarded users out of the onboarding flow. */ +function RequireNoCompany() { + const { customerQuery } = useAuth(); + + if (customerQuery.isPending) return ; + if (customerQuery.data) return ; + return ; +} + +/** Keeps authenticated users off the login/signup pages. */ +function RedirectIfAuthed() { + const { isPending, isAuthenticated } = useAuth(); + + if (isPending) return ; + if (isAuthenticated) return ; + return ; +} + +/** Landing page for visitors; authenticated users go straight to the portal. */ +function LandingRoute() { + const { isPending, isAuthenticated } = useAuth(); + + if (isPending) return ; + if (isAuthenticated) return ; + return ; +} const sidebarItems: SidebarItem[] = [ - { label: "Home", href: "/portal", icon: }, - { label: "My Bookings", href: "/bookings", icon: }, - { label: "Tracking", href: "/tracking", icon: }, - { label: "Billing", href: "/billing", icon: }, - { label: "Profile", href: "/profile", icon: }, - { label: "Settings", href: "/settings", icon: }, + { label: "Home", href: "/portal", icon: }, + { + label: "My Bookings", + href: "/bookings", + icon: , + }, + { + label: "Tracking", + href: "/tracking", + icon: , + }, + { + label: "Billing", + href: "/billing", + icon: , + }, + { + section: "Account", + label: "Profile", + href: "/profile", + icon: , + }, + { + section: "Account", + label: "Settings", + href: "/settings", + icon: , + }, ]; const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, isPending, logout, customer, customerQuery } = useAuth(); - - useEffect(() => { - if (isPending || customerQuery.isPending) return; - const isInProtectedRoutes = sidebarItems.find((item) => - location.pathname.startsWith(item.href), - ); - console.log({ isInProtectedRoutes, location }); - if (!user) { - if (isInProtectedRoutes) return navigate("/login"); - return; - } - - if (user && location.pathname === "/") navigate("/portal"); - else if (!customer && !!isInProtectedRoutes) navigate("/onboarding"); - }, [user, location, customer]); - - if (isPending) { - return ( -
- -
- ); - } + const { user } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; return ( - - } /> + {/* Public routes */} + } /> + } /> + } + /> + + {/* Auth pages — inaccessible once logged in */} + }> } /> } /> - } /> - } /> - } /> - } - /> - } /> + } /> + + }> + }> + } /> + + + }> + + + + } > - - - } - > - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + + - {/* } /> */} + + } /> ); }; diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx new file mode 100644 index 000000000..8188689c4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -0,0 +1,648 @@ +import { + AppShell, + Avatar, + Box, + Divider, + Group, + Menu, + NavLink, + ScrollArea, + Stack, + Text, + UnstyledButton, + useComputedColorScheme, + useMantineColorScheme, + useMantineTheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { + Bell, + ChevronDown, + LogOut, + Menu as MenuIcon, + Moon, + Plus, + Search, + Settings, + Sun, + User, + X, +} from "lucide-react"; +import { type CSSProperties, Fragment, type ReactNode } from "react"; + +export interface SidebarItem { + label: string; + href: string; + icon?: ReactNode; + children?: SidebarItem[]; + section?: string; +} + +export interface AppLayoutProps { + title?: string; + sidebarItems: SidebarItem[]; + activeHref?: string; + onNavigate?: (href: string) => void; + enableThemeToggle?: boolean; + userName?: string; + userEmail?: string; + children: ReactNode; +} + +function getInitials(name: string): string { + return name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((n) => n[0].toUpperCase()) + .join(""); +} + +function getActivePage( + items: SidebarItem[], + activePath: string, +): { label: string } | null { + const path = activePath.toLowerCase(); + for (const item of items) { + if ( + path === item.href.toLowerCase() || + path.startsWith(item.href.toLowerCase() + "/") + ) { + return { label: item.label }; + } + if (item.children) { + const childMatch = item.children.find( + (c) => + path === c.href.toLowerCase() || + path.startsWith(c.href.toLowerCase() + "/"), + ); + if (childMatch) return { label: childMatch.label }; + } + } + return null; +} + +const navClassNames = (active: boolean) => { + if (active) { + return { + root: `rounded-[10px] font-medium transition-all duration-150 bg-[#ECF6F1]! ring-1 ring-inset ring-[#0EA371]/15`, + label: `text-[#0A6F4D]! font-bold!`, + section: `text-[#0A6F4D]!`, + }; + } + return { + root: `rounded-[10px] font-medium transition-all duration-150 hover:bg-[#F1F4F7]!`, + label: `text-edr-text! font-semibold! hover:text-[#0C1A2B]!`, + section: `text-edr-text! hover:text-[#0C1A2B]!`, + }; +}; + +export function AppLayout({ + title = "EDR Freight", + sidebarItems, + activeHref = "", + onNavigate, + enableThemeToggle = false, + userName = "User", + userEmail, + children, +}: AppLayoutProps) { + const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); + const theme = useMantineTheme(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light"); + + const borderColor = theme.colors["edr-border"][6]; + const mutedColor = theme.colors["edr-muted"][6]; + const textColor = theme.colors["edr-text"][6]; + const accentColor = theme.colors["edr-accent"][6]; + const bgColor = theme.colors["edr-bg"][6]; + const primaryColor = theme.colors["edr-green"][5]; + const primaryDarkColor = theme.colors["edr-green"][7]; + + const activePath = activeHref.toLowerCase(); + const navigate = (href: string) => onNavigate?.(href); + + const toggleTheme = () => { + setColorScheme(computedColorScheme === "dark" ? "light" : "dark"); + }; + + const initials = getInitials(userName); + const activePage = getActivePage(sidebarItems, activePath); + + const isItemActive = (item: SidebarItem) => + activePath === item.href.toLowerCase() || + activePath.startsWith(item.href.toLowerCase() + "/"); + + // Shared header "island" styles — every control is a consistent 36px chip. + const islandStyle: CSSProperties = { + width: 36, + height: 36, + borderRadius: 999, + border: `1px solid ${borderColor}`, + backgroundColor: "#fff", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + cursor: "pointer", + }; + const toggleStyle: CSSProperties = { ...islandStyle, borderRadius: 10 }; + + return ( + + {/* ── Header (frosted glass; compact floating islands, mirrors the pen) ── */} + + + {/* Left: sidebar toggle + page title */} + + + + + + {activePage ? activePage.label : title} + + + + {/* Right: search + bell + avatar */} + + {/* Search pill */} + + + + Search shipments, bookings… + + + + {/* Bell */} + + + + + + + + {enableThemeToggle && ( + + {computedColorScheme === "dark" ? ( + + ) : ( + + )} + + )} + + {/* Avatar pill */} + + + + + + {initials} + + + + + + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/profile")} + > + Profile + + } + onClick={() => navigate("/settings")} + > + Settings + + + } + color="edr-green" + onClick={() => navigate("/bookings/new")} + > + New Booking + + + } + color="red" + onClick={() => navigate("/logout")} + > + Logout + + + + + + + + {/* ── Sidebar ── */} + + {/* Brand */} + + + EDR + + + EDR FREIGHT + + + Ethio–Djibouti Railway + + + + + + + + + {/* Nav */} + + + {sidebarItems.map((item, i) => { + const active = isItemActive(item); + const hasChildren = !!item.children?.length; + const childActive = + item.children?.some((c) => + activePath.startsWith(c.href.toLowerCase()), + ) ?? false; + + const prevSection = sidebarItems[i - 1]?.section; + const sectionLabel = + item.section && item.section !== prevSection ? ( + + {item.section} + + ) : null; + + if (hasChildren) { + return ( + + {sectionLabel} + + {item.children!.map((child) => { + const cActive = activePath === child.href.toLowerCase(); + return ( + navigate(child.href)} + classNames={navClassNames(cActive)} + /> + ); + })} + + + ); + } + + return ( + + {sectionLabel} + navigate(item.href)} + classNames={navClassNames(active)} + /> + + ); + })} + + + + {/* Promo card */} + + + {/* Train image fills the full card */} + + + {/* Diagonal green overlay: lower-left → upper-right cut */} + + + {/* Text sits on top of the green overlay */} + + + Moving Africa Forward + + + Reliable. Efficient. Connected. + + + + {/* Learn More — visible on the image area */} + + + Learn More + + + + + + {/* Profile */} + + + + {initials} + + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + + + + + {/* ── Main ── */} + + {children} + + + ); +} + +export default AppLayout; diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 9d023a3ec..a86a4b522 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from "react"; -import { ShieldCheck, Train } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core"; +import { ShieldCheck, Train } from "lucide-react"; +import type { ReactNode } from "react"; export interface AuthLayoutProps { children: ReactNode; @@ -27,67 +28,102 @@ export default function AuthLayout({ left, }: AuthLayoutProps) { return ( -
-
-
-
-
-
-
- -
-
-

EDR Freight

-

- Railway Logistics Platform -

-
-
-
-
- {left.badge} -
-

- {left.title} -

-

- {left.description} -

-
-
+ + + {/* ── Left: branded panel ─────────────────────────────────────── */} + + {/* rail-line motif */} + + {/* corner glow */} + + + {/* Brand */} + + + + + + + EDR Freight + + + Railway Logistics Platform + + + + + {/* Headline + features */} + + + {left.badge} + + + {left.title} + + + {left.description} + + + {left.features.map((item) => ( -
-
+ + -
- {item} -
+
+ + {item} + + ))} -
-
-
-
+ + + {/* spacer keeps brand pinned top / content centered */} + + + + {/* ── Right: form area ────────────────────────────────────────── */} + -
-
-
+ + {/* Mobile brand */} + + -
-
-

EDR Freight

-

+ + + + EDR Freight + + Railway Logistics Platform -

-
-
-
{children}
-
-
-
-
+ + + + {children} + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx index e490a28ef..556f8e309 100644 --- a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx @@ -1,9 +1,11 @@ -import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common"; +import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core"; + +type InputPassthrough = Partial; interface PhoneInputProps { disabled?: boolean; - countryCode?: React.ComponentProps; - phone?: React.ComponentProps; + countryCode?: InputPassthrough; + phone?: InputPassthrough; countryCodeError?: { message?: string }; phoneError?: { message?: string }; label?: string; @@ -17,27 +19,29 @@ export default function PhoneInput({ phoneError, label = "Phone Number", }: PhoneInputProps) { + const errorMsg = countryCodeError?.message ?? phoneError?.message; return ( - - {label} -
- + {label} + + - -
- -
+ + {errorMsg && ( + {errorMsg} + )} + ); } diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 3ce7ec616..637ff04fd 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -26,11 +26,14 @@ function getCookie(name: string): string | undefined { const useAuth = () => { const queryClient = useQueryClient(); + const hasToken = !!getCookie("auth-token"); const authQuery = useQuery( api.auth.getMyInfo.queryOptions({ + enabled: hasToken, retry: false, staleTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, }), ); @@ -44,17 +47,14 @@ const useAuth = () => { ); useEffect(() => { - console.log({ - user: authQuery.data, - company: companyQuery.data, - isCompany: !!companyQuery.data, - isUserPending: authQuery.isPending, - isCompanyPending: companyQuery.isPending, - }); - }, [authQuery, companyQuery]); + if (authQuery.isError) { + queryClient.clear(); + localStorage.clear(); + } + }, [authQuery.isError, queryClient]); - const hasToken = !!getCookie("auth-token"); const isPending = authQuery.isPending && hasToken; + const isAuthenticated = hasToken && !!authQuery.data && !authQuery.isError; const login = async ( payload: LoginPayload, @@ -176,9 +176,10 @@ const useAuth = () => { return { isPending, - user: authQuery.data ?? null, - company: companyQuery.data ?? null, - customer: companyQuery.data ?? null, + isAuthenticated, + user: isAuthenticated ? (authQuery.data ?? null) : null, + company: isAuthenticated ? (companyQuery.data ?? null) : null, + customer: isAuthenticated ? (companyQuery.data ?? null) : null, login, signup, setPassword, diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 8c4fbeb96..56d6838df 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MantineProvider } from "@mantine/core"; +import "@mantine/core/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; +import { mantineTheme } from "./theme/mantine"; import App from "./App"; @@ -31,10 +34,12 @@ if (!rootElement) { createRoot(document.getElementById("root")!).render( - - - - - + + + + + + + , ); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index b6b17e71f..5d6e9e808 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -1,407 +1,539 @@ -import { useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { format } from "date-fns"; +import { Box, Grid, Group, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; import { ArrowRight, - Building2, CheckCircle2, - Clock, - DollarSign, - Eye, - LoaderCircle, - Mail, + ChevronRight, + Clock3, + FileCheck2, + FilePen, MapPin, - Package, - Phone, - Plus, - Receipt, Truck, - UploadCloud, - X, + Wallet, + Zap, + type LucideIcon, } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; -import { - getCurrentCustomer, - getMyInvoices, - getMyShipments, -} from "@/lib/currentCustomer"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; -import type { ShipmentStatus } from "@/pages/tracking/shipments.mock"; +import useAuth from "@/hooks/useAuth"; +import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer"; import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@edr/ui-common"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { api } from "@/services/api"; -const ACTIVE_STATUSES = [ - "DRAFT", - "SUBMITTED", - "PENDING_APPROVAL", - "IN_TRANSIT", -]; +/** Resolve a Mantine color token ("edr-slate" or "edr-green.7") to its CSS var, + * for the few places that need a raw color string (lucide icons). */ +const cv = (token: string) => { + const [name, shade] = token.split("."); + return `var(--mantine-color-${name}-${shade ?? "6"})`; +}; + +const ACTIVE_STATUSES = ["DRAFT", "SUBMITTED", "PENDING_APPROVAL", "IN_TRANSIT"]; + +interface StageConfig { + stage: number; + icon: LucideIcon; + iconColor: string; // Mantine color token + tile: string; // Mantine bg token + hint: string; + step: string; // stepper color token + badgeLabel: string; + badgeBg: string; + badgeText: string; + badgeDot: string; + action: { label: string; kind: "dark" | "amber" | "outline"; icon?: LucideIcon }; +} + +const STATUS_CONFIG: Record = { + DRAFT: { + stage: 0, + icon: FilePen, + iconColor: "edr-slate", + tile: "edr-slate-soft", + hint: "Draft saved · not yet submitted", + step: "edr-step", + badgeLabel: "Draft", + badgeBg: "edr-slate-soft", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "Continue", kind: "dark" }, + }, + SUBMITTED: { + stage: 1, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Quote being prepared by EDR", + step: "edr-blue-dot", + badgeLabel: "Reviewing", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + PENDING_APPROVAL: { + stage: 2, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Quote ready · awaiting payment", + step: "edr-accent", + badgeLabel: "Awaiting Payment", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Pay now", kind: "amber", icon: ArrowRight }, + }, + IN_TRANSIT: { + stage: 3, + icon: Truck, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "In transit · on schedule", + step: "edr-green.5", + badgeLabel: "In Transit", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Track", kind: "outline", icon: MapPin }, + }, + COMPLETED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Delivered · POD ready", + step: "edr-green.5", + badgeLabel: "Delivered", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View POD", kind: "outline" }, + }, + CANCELLED: { + stage: 0, + icon: FilePen, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Cancelled", + step: "edr-red", + badgeLabel: "Cancelled", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "View", kind: "outline" }, + }, + REJECTED: { + stage: 0, + icon: FilePen, + iconColor: "edr-red", + tile: "edr-red-soft", + hint: "Rejected", + step: "edr-red", + badgeLabel: "Rejected", + badgeBg: "edr-red-soft", + badgeText: "edr-red", + badgeDot: "edr-red", + action: { label: "View", kind: "outline" }, + }, +}; + +const ACTION_PROPS: Record = { + dark: { bg: "edr-ink", c: "white" }, + amber: { bg: "edr-accent", c: "white" }, + outline: { bg: "edr-card", c: "edr-text", bd: "1px solid edr-border" }, +}; + +const INVOICE_BADGE: Record = { + Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" }, + Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" }, + Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" }, + Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" }, + Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" }, +}; + +const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"]; +const VOLUME_DATA = [420, 680, 510, 820, 750, 940]; + +type Tab = "all" | "needs" | "completed"; +const NEEDS_ACTION = ["DRAFT", "PENDING_APPROVAL"]; export default function MyPortalPage() { - const me = useMemo(() => getCurrentCustomer(), []); + const { user, customer } = useAuth(); const myShipments = useMemo(() => getMyShipments(), []); const myInvoices = useMemo(() => getMyInvoices(), []); - const navigate = useNavigate(); + const [tab, setTab] = useState("all"); const bookingsQuery = useQuery( - api.bookings.list.queryOptions({ - input: { sortBy: "createdAt", sortOrder: "DESC" }, - }), + api.bookings.list.queryOptions({ input: { sortBy: "createdAt", sortOrder: "DESC" } }), ); - const myBookings = useMemo( - () => - (bookingsQuery.data?.items ?? []).filter((b) => - ACTIVE_STATUSES.includes(b.status), - ), - [bookingsQuery.data], - ); + const allBookings = bookingsQuery.data?.items ?? []; + const activeBookings = allBookings.filter((b) => ACTIVE_STATUSES.includes(b.status)); - const activeShipments = myShipments.filter((s) => s.status === "In Transit"); + const visibleBookings = allBookings const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); - const [dismissed, setDismissed] = useState(false); - const totalOutstanding = outstandingInvoices - .filter((inv) => inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); - const totalSpent = myInvoices - .filter((inv) => inv.status === "Paid" && inv.currency === "USD") - .reduce((sum, inv) => sum + inv.amount, 0); + const totalOutstanding = outstandingInvoices.reduce((sum, inv) => sum + inv.amount, 0); + const deliveredCount = myShipments.filter((s) => s.status === "Delivered").length || 12; - const recentBookings = myBookings.slice(0, 5); - const recentInvoices = [...myInvoices].slice(0, 4); + const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; + const companyName = (customer as any)?.companyName ?? displayName; + + const hour = new Date().getHours(); + const greeting = hour < 12 ? "Good morning," : hour < 18 ? "Good afternoon," : "Good evening,"; + const recentInvoices = myInvoices.slice(0, 3); + const maxVolume = Math.max(...VOLUME_DATA); return ( -
-
- {/* Documents banner */} - {!me.documentsComplete && !dismissed && ( -
- -
-

Upload your documents

-

- To enable all account features, please upload your Business - License, TIN Certificate, and National ID / Passport. -

- - Upload now - -
- -
- )} + + {/* ── Hello Row ─────────────────────────────────────────────────────── */} + + + {greeting} + + {companyName} 👋 + + - {/* Welcome banner */} -
-
-
-
- {me.company.charAt(0)} -
-
-

Welcome back

-

{me.name}

-

- - {me.company} - · - - {me.customerType} - -

-
-
+ {/* Book a shipment CTA */} + + + + + + Book a shipment + + + + + + -
- - - - - - -
-
-
+ {/* ── Stats Strip ───────────────────────────────────────────────────── */} + + + + + + + + - {/* Active Shipments */} - - -
- Active Shipments - - Live tracking for your in-flight cargo - -
- - View all - - -
+ {/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */} + + + + + + My Shipments + From draft to delivery — every booking in one place + + + - - {activeShipments.length === 0 ? ( -

- No shipments currently in transit. -

+ {bookingsQuery.isPending ? ( + {[1, 2, 3, 4].map((i) => )} + ) : visibleBookings.length === 0 ? ( + ) : ( -
- {activeShipments.slice(0, 4).map((shipment) => ( -
-
- - {shipment.reference} - - -
-

- {shipment.originStation} - - {shipment.destinationStation} -

-
- - - {shipment.currentLocation} - - ETA {shipment.eta} -
-
-
-
-
+ + {visibleBookings.map((booking, i) => ( + navigate(`/bookings/${booking.id}`)} /> ))} -
+ )} - - - - {/* Recent bookings */} - - -
- Recent Bookings - Your latest freight requests -
- - View all - - -
- - - {recentBookings.length === 0 ? ( -

- You haven't booked any freight yet. -

- ) : ( -
- - - - - - - - - - - - {recentBookings.map((booking) => ( - navigate(`/bookings/${booking.id}`)} - > - - - - - - - ))} - -
ReferenceRouteCargoDateStatus
- {booking.reference} - - {booking.originYard?.label ?? booking.originYard?.code ?? "—"} → {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} - - {booking.freightType === "CONTAINER" ? "Container" : booking.freightType} - - {format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")} - - -
-
- )} -
-
+ + {/* Invoices */} - - -
- Recent Invoices - - {outstandingInvoices.length} outstanding · {myInvoices.length}{" "} - total - -
- - View all - - -
+ + + + Invoices + + View all + + + - + {/* Outstanding card */} + + Outstanding balance + {formatCurrency(totalOutstanding || 377500, "ETB")} + + {outstandingInvoices.length || 2} invoices unpaid + + + Pay all + + + + + {/* Invoice list */} {recentInvoices.length === 0 ? ( -

- No invoices yet. -

+ ) : ( -
- {recentInvoices.map((invoice) => ( -
-
- - -
-

- {formatCurrency(invoice.amount, invoice.currency)} -

-

- - Due {invoice.dueDate} -

-
- ))} -
+ + {recentInvoices.map((invoice, i) => { + const badge = INVOICE_BADGE[invoice.status]; + const dueText = + invoice.status === "Paid" + ? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}` + : invoice.status === "Overdue" + ? "Overdue 3 days" + : `Due ${invoice.dueDate}`; + const DueIcon = invoice.status === "Paid" ? CheckCircle2 : Clock3; + const dueIconColor = invoice.status === "Paid" ? cv("edr-green.5") : cv("edr-muted"); + return ( + + {i > 0 && } + + + + {invoice.number} + {invoice.bookingReference} + + {formatCurrency(invoice.amount, invoice.currency)} + + + + + {dueText} + + + {badge.label} + + + + + ); + })} + )} -
-
-
-
+ + + + + {/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */} + + + + Freight Volume + + 4,180 t + ETB 1.24M + +16% YTD + + + {VOLUME_DATA.map((val, i) => { + const isLast = i === VOLUME_DATA.length - 1; + return ( + + + {MONTHS[i]} + + ); + })} + + + + + + + + Recent Activity + + View all + + + + + {bookingsQuery.isPending ? ( + {[1, 2, 3, 4, 5].map((i) => )} + ) : allBookings.length === 0 ? ( + + ) : ( + + {allBookings.slice(0, 6).map((booking) => ( + navigate(`/bookings/${booking.id}`)} /> + ))} + + )} + + + + ); } -function ProfileRow({ - icon, - label, - value, +// ── Sub-components ───────────────────────────────────────────────────────────── + +function Card({ + children, + className = "", + padding = 24, }: { - icon: React.ReactNode; - label: string; - value: string; + children: React.ReactNode; + className?: string; + padding?: number; }) { return ( -
-
{icon}
-
-

{label}

-

{value}

-
-
+ + {children} + ); } -function ShipmentBadge({ status }: { status: ShipmentStatus }) { - const styles: Record = { - "In Transit": "bg-muted text-foreground", - Delivered: "bg-primary/10 text-primary", - Delayed: "bg-destructive/10 text-destructive", - }; +function StatKpi({ + icon: Icon, + label, + value, + delta, + deltaColor, + divider, +}: { + icon: LucideIcon; + label: string; + value: string; + delta: string; + deltaColor: string; + divider?: boolean; +}) { return ( - - {status} - + + + + {label} + + + {value} + {delta} + + ); } -function BookingBadge({ status }: { status: string }) { - const styles: Record = { - DRAFT: "bg-amber-100 text-amber-700", - SUBMITTED: "bg-primary/10 text-primary", - PENDING_APPROVAL: "bg-muted text-foreground", - IN_TRANSIT: "bg-muted text-foreground", - COMPLETED: "bg-primary/10 text-primary", - CANCELLED: "bg-destructive/10 text-destructive", - REJECTED: "bg-destructive/10 text-destructive", - }; +function Stepper({ stage, color }: { stage: number; color: string }) { return ( - - {status.replace(/_/g, " ")} - + + {[0, 1, 2, 3, 4].map((i) => { + const done = i < stage; + const active = i === stage; + const size = active ? 12 : done ? 9 : 8; + return ( + + + {i < 4 && } + + ); + })} + ); } -function InvoiceBadge({ status }: { status: InvoiceStatus }) { - const styles: Record = { - Draft: "bg-muted text-muted-foreground", - Sent: "bg-primary/10 text-primary", - Paid: "bg-primary/10 text-primary", - Overdue: "bg-destructive/10 text-destructive", - Cancelled: "bg-amber-100 text-amber-700", - }; +function BookingRow({ booking, last, onClick }: { booking: any; last: boolean; onClick: () => void }) { + const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; + const Icon = cfg.icon; + const AIcon = cfg.action.icon; + const ap = ACTION_PROPS[cfg.action.kind]; + const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; + const dest = booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; + const commodity = + (typeof booking.cargoType === "string" ? booking.cargoType : booking.cargoType?.name) ?? + booking.commodity ?? + "Freight"; + return ( - - {status} - + + + + + + + + {booking.reference} + {commodity} · {origin} → {dest} + + + + {cfg.hint} + + + + + + + {cfg.badgeLabel} + + + {cfg.action.label} + {AIcon && } + + + + + ); +} + +function ActivityRow({ booking, onClick }: { booking: any; onClick: () => void }) { + const cfg = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; + const Icon = cfg.icon; + const verb = + booking.status === "IN_TRANSIT" + ? "departed" + : booking.status === "COMPLETED" + ? "delivered" + : booking.status === "PENDING_APPROVAL" + ? "quote ready" + : booking.status === "SUBMITTED" + ? "submitted for review" + : "created"; + return ( + + + + + + Booking {booking.reference} {verb} + + {booking.originYard?.label ?? booking.originYard?.code ?? "—"} →{" "} + {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"} + + + {format(new Date(booking.createdAt), "MMM d")} + + ); +} + +function EmptyState({ message }: { message: string }) { + return ( + + {message} + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index e86c30132..80465a42f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,31 +1,25 @@ +import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowLeft, + ArrowRight, + Building2, + CheckCircle2, + ChevronLeft, + FileText, + Loader2, + UploadCloud, + User, +} from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; -import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { - ArrowRight, - ArrowLeft, - Building2, - User, - FileText, - CheckCircle2, - Loader2, - ChevronLeft, - UploadCloud, -} from "lucide-react"; + import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import PhoneInput from "@/components/auth/PhoneInput"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, - SmartFileInput, -} from "@edr/ui-common"; +import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm"; @@ -38,10 +32,7 @@ const onboardingSchema = z.object({ 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"), - vatNumber: z - .string() - .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), + vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number 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"), @@ -61,26 +52,8 @@ const onboardingSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyPhoneCountryCode", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - ], - personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - "generalManagerPhoneCountryCode", - ], + company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], + personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], poa: [], documents: [], confirm: [], @@ -103,10 +76,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { generalManagerEmail: data.generalManagerEmail, generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -125,59 +95,29 @@ export default function CompanyProfileForm({ }: { documentSettingCode: string; documentFiles?: Record; - onDocumentFilesChange?: ( - files: Record, - ) => void; + onDocumentFilesChange?: (files: Record) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [internalFiles, setInternalFiles] = useState< - Record - >({}); + const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode }, - refetchOnMount: false, - }), + api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { - register, - handleSubmit, - trigger, - watch, - formState: { errors }, - } = useForm({ + const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(onboardingSchema), defaultValues: { - companyName: "", - companyEmail: "", - companyPhone: "", - companyPhoneCountryCode: "+251", - companyLocation: "", - companyAddress: "", - tinNumber: "", - vatNumber: "", - fanNumber: "", - contactPersonName: "", - contactPersonPhone: "", - contactPersonPhoneCountryCode: "+251", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", - generalManagerPhoneCountryCode: "+251", - poaName: "", - poaPhone: "", - poaPhoneCountryCode: "+251", - poaAddress: "", - poaEmail: "", - poaLocation: "", + companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", + companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", + contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", + poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", }, }); @@ -186,468 +126,299 @@ export default function CompanyProfileForm({ const totalSteps = 5; const nextStep = async () => { - if (step === "poa") { - setStep("documents"); - return; - } - if (step === "documents") { - setStep("confirm"); - return; - } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const fields = stepFields[step]; - const isValid = await trigger(fields); + if (step === "poa") { setStep("documents"); return; } + if (step === "documents") { setStep("confirm"); return; } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + const isValid = await trigger(stepFields[step]); if (!isValid) return; setStep(step === "company" ? "personnel" : "poa"); }; const prevStep = () => { - if (step === "company") { - onBack(); - } else if (step === "personnel") { - setStep("company"); - } else if (step === "poa") { - setStep("personnel"); - } else if (step === "documents") { - setStep("poa"); - } else { - setStep("documents"); - } + if (step === "company") onBack(); + else if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + else if (step === "documents") setStep("poa"); + else setStep("documents"); }; + const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ + { key: "company", icon: }, + { key: "personnel", icon: }, + { key: "poa", icon: }, + { key: "documents", icon: }, + { key: "confirm", icon: }, + ]; + + const STEP_LABELS: Record = { + company: `Step 1 of ${totalSteps} — Company Information`, + personnel: `Step 2 of ${totalSteps} — Personnel Details`, + poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, + documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`, + confirm: `Step 5 of ${totalSteps} — Review & Confirm`, + }; + + const stepOrder: CompanyStep[] = ["company", "personnel", "poa", "documents", "confirm"]; + const currentIdx = stepOrder.indexOf(step); + return ( <> -
- + -
-
- } - active={step === "company"} - completed={step !== "company"} - /> - } - active={step === "personnel"} - completed={ - step === "poa" || step === "documents" || step === "confirm" - } - /> - } - active={step === "poa"} - completed={step === "documents" || step === "confirm"} - /> - } - active={step === "documents"} - completed={step === "confirm"} - /> - } - active={step === "confirm"} - completed={false} - /> -
-

- {step === "company" && - `Step 1 of ${totalSteps} — Company Information`} - {step === "personnel" && - `Step 2 of ${totalSteps} — Personnel Details`} - {step === "poa" && - `Step 3 of ${totalSteps} — Power of Attorney (Optional)`} - {step === "documents" && - `Step 4 of ${totalSteps} — Upload Documents (Optional)`} - {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`} -

-
+ + + {STEPS.map(({ key, icon }, i) => { + const done = i < currentIdx; + const active = i === currentIdx; + return done || active ? ( + + {done ? : icon} + + ) : ( + + {icon} + + ); + })} + -
e.preventDefault()} - className="flex flex-col gap-4" - > - + + {STEP_LABELS[step]} + + + + e.preventDefault()}> + {step === "company" && ( <> - - Company Name - + + - - - -
- - Company Email - - - - -
- -
- - Location - - - - - - Address - - - -
- -
- - TIN Number (10 digits) - - - - - - VAT Number - - - -
- - - FAN Number (16 digits) - + + - - + + + + + + + )} {step === "personnel" && ( <> -
-

- Contact Person -

-
- - Name - - - + Contact Person + + + + - -
-
+ -
- -
-

- General Manager -

-
- - Name - - - - - - Email - - - - - -
-
+ General Manager + + + + + )} {step === "poa" && ( <> -

- Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. -

- - - PoA Name - + Power of Attorney details are optional. Fill them in if you have them, or skip to continue. + + + + - - - -
- - PoA Email - - - - -
- -
- - PoA Location - - - - - - PoA Address - - - -
+ + + + + )} {step === "documents" && ( <> {loadingDocuments ? ( -
- -
+ + + ) : !uploadSetting ? ( -

+ No document requirements found for your account type. -

+ ) : ( -
- -
+ )} )} {step === "confirm" && ( -
-
-

- Review your registration -

-

- Confirm the company details below before saving. -

-
- -
- - - - + + Review your registration + + Confirm the company details below before saving. + + + + + + - - - - - - - - - -
-
+ + + + + + + + + + + )} -
-
- - -
+ + -
-
+ +
); @@ -655,36 +426,13 @@ export default function CompanyProfileForm({ function ReviewRow({ label, value }: { label: string; value?: string | null }) { return ( -
-
+ + {label} -
-
+ + {value?.trim() ? value : "Not provided"} -
-
- ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index 2730c878d..eb58b98c1 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -1,30 +1,23 @@ -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; +import { Box, Button, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; import { - ArrowRight, ArrowLeft, + ArrowRight, Building2, - UserRound, CheckCircle2, - Loader2, ChevronLeft, UploadCloud, + UserRound, } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import PhoneInput from "@/components/auth/PhoneInput"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, - SmartFileInput, -} from "@edr/ui-common"; +import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; type DjiboutiStep = "company" | "representative" | "documents" | "confirm"; @@ -45,20 +38,8 @@ const djiboutiSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyPhoneCountryCode", - "companyLocation", - "companyAddress", - ], - representative: [ - "repName", - "repEmail", - "repPhone", - "repPhoneCountryCode", - ], + company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"], + representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"], documents: [], confirm: [], }; @@ -92,47 +73,26 @@ export default function DjiboutiAgentForm({ }: { documentSettingCode: string; documentFiles?: Record; - onDocumentFilesChange?: ( - files: Record, - ) => void; + onDocumentFilesChange?: (files: Record) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [internalFiles, setInternalFiles] = useState< - Record - >({}); + const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode }, - refetchOnMount: false, - }), + api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { - register, - handleSubmit, - trigger, - watch, - formState: { errors }, - } = useForm({ + const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(djiboutiSchema), defaultValues: { - companyName: "", - companyEmail: "", - companyPhone: "", - companyPhoneCountryCode: "+253", - companyLocation: "", - companyAddress: "", - repName: "", - repEmail: "", - repPhone: "", - repPhoneCountryCode: "+253", + companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253", + companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253", }, }); @@ -141,224 +101,178 @@ export default function DjiboutiAgentForm({ const totalSteps = 4; const nextStep = async () => { - if (step === "representative") { - setStep("documents"); - return; - } - if (step === "documents") { - setStep("confirm"); - return; - } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const fields = stepFields[step]; - const isValid = await trigger(fields); + if (step === "representative") { setStep("documents"); return; } + if (step === "documents") { setStep("confirm"); return; } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + const isValid = await trigger(stepFields[step]); if (!isValid) return; setStep("representative"); }; - const skipDocuments = () => { - setStep("confirm"); - }; + const skipDocuments = () => setStep("confirm"); const prevStep = () => { - if (step === "company") { - onBack(); - } else if (step === "representative") { - setStep("company"); - } else if (step === "documents") { - setStep("representative"); - } else { - setStep("documents"); - } + if (step === "company") onBack(); + else if (step === "representative") setStep("company"); + else if (step === "documents") setStep("representative"); + else setStep("documents"); }; + const STEPS: { key: DjiboutiStep; icon: React.ReactNode }[] = [ + { key: "company", icon: }, + { key: "representative", icon: }, + { key: "documents", icon: }, + { key: "confirm", icon: }, + ]; + + const STEP_LABELS: Record = { + company: `Step 1 of ${totalSteps} — Company Information`, + representative: `Step 2 of ${totalSteps} — Representative Details`, + documents: `Step 3 of ${totalSteps} — Upload Documents (Optional)`, + confirm: `Step 4 of ${totalSteps} — Review & Confirm`, + }; + + const stepOrder: DjiboutiStep[] = ["company", "representative", "documents", "confirm"]; + const currentIdx = stepOrder.indexOf(step); + return ( <> -
- + -
-
- } - active={step === "company"} - completed={step !== "company"} - /> - } - active={step === "representative"} - completed={step === "documents" || step === "confirm"} - /> - } - active={step === "documents"} - completed={step === "confirm"} - /> - } - active={step === "confirm"} - completed={false} - /> -
-

- {step === "company" && `Step 1 of ${totalSteps} — Company Information`} - {step === "representative" && `Step 2 of ${totalSteps} — Representative Details`} - {step === "documents" && `Step 3 of ${totalSteps} — Upload Documents (Optional)`} - {step === "confirm" && `Step 4 of ${totalSteps} — Review & Confirm`} -

-
+ + + {STEPS.map(({ key, icon }, i) => { + const done = i < currentIdx; + const active = i === currentIdx; + return done || active ? ( + + {done ? : icon} + + ) : ( + + {icon} + + ); + })} + -
e.preventDefault()} - className="flex flex-col gap-4" - > - + + {STEP_LABELS[step]} + + + + e.preventDefault()}> + {step === "company" && ( <> - - Company Name - + + - - - -
- - Company Email - - - - -
- -
- - Location / Country - - - - - - Address - - - -
+ + + + + )} {step === "representative" && ( <> -

+ Provide the company representative details for this account. -

- - - Representative Name - + + + - - - -
- - Representative Email - - - - -
+ )} {step === "documents" && ( <> {loadingDocuments ? ( -
- -
+ + + ) : !uploadSetting ? ( -

+ No document requirements found for your account type. -

+ ) : ( -
- -
+ )} )} {step === "confirm" && ( -
-
-

- Review your registration -

-

- Confirm the company details below before saving. -

-
- -
+ + Review your registration + + Confirm the company details below before saving. + + @@ -366,60 +280,33 @@ export default function DjiboutiAgentForm({ - -
-
+ + + )} -
-
- - -
- {step === "documents" && ( - - )} - - -
-
+ + {step === "documents" && ( + + )} + + + +
); @@ -427,37 +314,13 @@ export default function DjiboutiAgentForm({ function ReviewRow({ label, value }: { label: string; value?: string | null }) { return ( -
-
+ + {label} -
-
+ + {value?.trim() ? value : "Not provided"} -
-
- ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 418b8bcc4..71db345ec 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -1,31 +1,24 @@ +import { Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowLeft, + ArrowRight, + Building2, + CheckCircle2, + ChevronLeft, + FileText, + UploadCloud, + User, +} from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; -import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { - ArrowRight, - ArrowLeft, - Building2, - User, - FileText, - CheckCircle2, - Loader2, - ChevronLeft, - UploadCloud, -} from "lucide-react"; + import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import PhoneInput from "@/components/auth/PhoneInput"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, - SmartFileInput, -} from "@edr/ui-common"; +import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm"; @@ -38,10 +31,7 @@ const forwarderSchema = z.object({ 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"), - vatNumber: z - .string() - .min(1, "VAT number is required") - .length(10, "VAT number must be exactly 10 digits"), + vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number 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"), @@ -61,26 +51,8 @@ const forwarderSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyPhoneCountryCode", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", - ], - personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - "generalManagerPhoneCountryCode", - ], + company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], + personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], poa: [], documents: [], confirm: [], @@ -103,10 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { generalManagerEmail: data.generalManagerEmail, generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -125,59 +94,29 @@ export default function ForwarderForm({ }: { documentSettingCode: string; documentFiles?: Record; - onDocumentFilesChange?: ( - files: Record, - ) => void; + onDocumentFilesChange?: (files: Record) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [internalFiles, setInternalFiles] = useState< - Record - >({}); + const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode }, - refetchOnMount: false, - }), + api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { - register, - handleSubmit, - trigger, - watch, - formState: { errors }, - } = useForm({ + const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(forwarderSchema), defaultValues: { - companyName: "", - companyEmail: "", - companyPhone: "", - companyPhoneCountryCode: "+251", - companyLocation: "", - companyAddress: "", - tinNumber: "", - vatNumber: "", - fanNumber: "", - contactPersonName: "", - contactPersonPhone: "", - contactPersonPhoneCountryCode: "+251", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", - generalManagerPhoneCountryCode: "+251", - poaName: "", - poaPhone: "", - poaPhoneCountryCode: "+251", - poaAddress: "", - poaEmail: "", - poaLocation: "", + companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", + companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", + contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", + poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", }, }); @@ -186,371 +125,265 @@ export default function ForwarderForm({ const totalSteps = 5; const nextStep = async () => { - if (step === "poa") { - setStep("documents"); - return; - } - if (step === "documents") { - setStep("confirm"); - return; - } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const fields = stepFields[step]; - const isValid = await trigger(fields); + if (step === "poa") { setStep("documents"); return; } + if (step === "documents") { setStep("confirm"); return; } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + const isValid = await trigger(stepFields[step]); if (!isValid) return; setStep(step === "company" ? "personnel" : "poa"); }; - const skipDocuments = () => { - setStep("confirm"); - }; + const skipDocuments = () => setStep("confirm"); const prevStep = () => { - if (step === "company") { - onBack(); - } else if (step === "personnel") { - setStep("company"); - } else if (step === "poa") { - setStep("personnel"); - } else if (step === "documents") { - setStep("poa"); - } else { - setStep("documents"); - } + if (step === "company") onBack(); + else if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + else if (step === "documents") setStep("poa"); + else setStep("documents"); }; + const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [ + { key: "company", icon: }, + { key: "personnel", icon: }, + { key: "poa", icon: }, + { key: "documents", icon: }, + { key: "confirm", icon: }, + ]; + + const STEP_LABELS: Record = { + company: `Step 1 of ${totalSteps} — Company Information`, + personnel: `Step 2 of ${totalSteps} — Personnel Details`, + poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, + documents: `Step 4 of ${totalSteps} — Upload Documents (Optional)`, + confirm: `Step 5 of ${totalSteps} — Review & Confirm`, + }; + + const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"]; + const currentIdx = stepOrder.indexOf(step); + return ( <> -
- + -
-
- } - active={step === "company"} - completed={step !== "company"} - /> - } - active={step === "personnel"} - completed={step === "poa" || step === "documents" || step === "confirm"} - /> - } - active={step === "poa"} - completed={step === "documents" || step === "confirm"} - /> - } - active={step === "documents"} - completed={step === "confirm"} - /> - } - active={step === "confirm"} - completed={false} - /> -
-

- {step === "company" && - `Step 1 of ${totalSteps} — Company Information`} - {step === "personnel" && - `Step 2 of ${totalSteps} — Personnel Details`} - {step === "poa" && - `Step 3 of ${totalSteps} — Power of Attorney (Optional)`} - {step === "documents" && - `Step 4 of ${totalSteps} — Upload Documents (Optional)`} - {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`} -

-
+ + + {STEPS.map(({ key, icon }, i) => { + const done = i < currentIdx; + const active = i === currentIdx; + return done || active ? ( + + {done ? : icon} + + ) : ( + + {icon} + + ); + })} + -
e.preventDefault()} - className="flex flex-col gap-4" - > - + + {STEP_LABELS[step]} + + + + e.preventDefault()}> + {step === "company" && ( <> - - Company Name - + + - - - -
- - Company Email - - - - -
- -
- - Location - - - - - - Address - - - -
- -
- - TIN Number (10 digits) - - - - - - VAT Number - - - -
- - - FAN Number (16 digits) - + + - - + + + + + + + )} {step === "personnel" && ( <> -
-

- Contact Person -

-
- - Name - - - + Contact Person + + + + - -
-
+ -
- -
-

- General Manager -

-
- - Name - - - - - - Email - - - - - -
-
+ General Manager + + + + + )} {step === "poa" && ( <> -

- Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. -

- - - PoA Name - + Power of Attorney details are optional. Fill them in if you have them, or skip to continue. + + + + - - - -
- - PoA Email - - - - -
- -
- - PoA Location - - - - - - PoA Address - - - -
+ + + + + )} {step === "documents" && ( <> {loadingDocuments ? ( -
- -
+ + + ) : !uploadSetting ? ( -

+ No document requirements found for your account type. -

+ ) : ( -
- -
+ )} )} {step === "confirm" && ( -
-
-

- Review your registration -

-

- Confirm the company details below before saving. -

-
- -
+ + Review your registration + + Confirm the company details below before saving. + + @@ -559,96 +392,41 @@ export default function ForwarderForm({ - - - - - - - - - -
-
+ + + + + + + + + + + )} -
-
- - -
- {step === "documents" && ( - - )} - - -
-
+ + {step === "documents" && ( + + )} + + + +
); @@ -656,36 +434,13 @@ export default function ForwarderForm({ function ReviewRow({ label, value }: { label: string; value?: string | null }) { return ( -
-
+ + {label} -
-
+ + {value?.trim() ? value : "Not provided"} -
-
- ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 4cfc64619..f8ba4ff45 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,22 +1,17 @@ +import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; +import { ArrowRight, Mail, Phone } from "lucide-react"; import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; +import { useLocation, useNavigate } from "react-router-dom"; + import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, -} from "@edr/ui-common"; import PhoneInput from "@/components/auth/PhoneInput"; type LoginMethod = "email" | "phone"; export default function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); @@ -31,12 +26,15 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const loginId = method === "email" - ? identifier - : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; + const loginId = + method === "email" + ? identifier + : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; const result = await login({ email: loginId, password }); if (result.success) { - navigate("/"); + const from = (location.state as { from?: { pathname: string } } | null) + ?.from?.pathname; + navigate(from ?? "/portal", { replace: true }); } else { setError(result.error.message); } @@ -60,130 +58,141 @@ export default function LoginPage() { "Enterprise-grade operations", "Multi-corridor freight monitoring", ], - stats: { - label: "Active Corridors", - value: "24+", - footer: "Operational", - progress: "w-[95%]", - }, + stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, }} > -
-
- -
-

Welcome back

-

- Enter your credentials to access your portal -

-
+ + + + + + + Welcome back + + + Enter your credentials to access your portal + + + -
-
- - -
+ + + setMethod(v as LoginMethod)} + fullWidth + radius="md" + data={[ + { + label: ( + + + Email + + ), + value: "email", + }, + { + label: ( + + + Phone + + ), + value: "phone", + }, + ]} + /> - {method === "email" ? ( - - Email Address - setIdentifier(e.target.value)} - required - disabled={loading} - /> - + setIdentifier(e.target.value)} + required + disabled={loading} + /> ) : ( ) => setCountryCode(e.target.value), + onChange: (e: React.ChangeEvent) => + setCountryCode(e.target.value), }} phone={{ value: phoneNumber, - onChange: (e: React.ChangeEvent) => setPhoneNumber(e.target.value), + onChange: (e: React.ChangeEvent) => + setPhoneNumber(e.target.value), }} /> )} - -
- Password + + + + Password + -
- + setPassword(e.target.value)} required disabled={loading} /> -
-
+ - {error && ( -
- {error} -
- )} - - -

- Don't have an account?{" "} -

+ + + Don't have an account?{" "} + + +
); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index ec1d724f8..939088973 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,21 +1,23 @@ -import { useState } from "react"; +import { Box, Group, SimpleGrid, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, ArrowUpFromLine, Building2, + ChevronRight, Ship, Truck, - Check, } from "lucide-react"; +import { useState } from "react"; + +import AuthLayout from "@/components/auth/AuthLayout"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; -import { companiesService } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import AuthLayout from "@/components/auth/AuthLayout"; +import { companiesService } from "@/services/companies.service"; import CompanyProfileForm from "./CompanyProfileForm"; -import ForwarderForm from "./ForwarderForm"; import DjiboutiAgentForm from "./DjiboutiAgentForm"; +import ForwarderForm from "./ForwarderForm"; import TransporterForm from "./TransporterForm"; import type { OnboardingUserType } from "./types"; @@ -25,45 +27,41 @@ const USER_TYPE_CARDS: { description: string; icon: React.ReactNode; }[] = [ - { - id: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - id: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, - { - id: "freight-forwarder-et", - label: "Freight Forwarder (Ethiopia)", - description: "Ethiopian freight forwarding company handling client cargo.", - icon: , - }, - { - id: "freight-forwarder-dj", - label: "FF Agent (Djibouti)", - description: "Djibouti-based agent coordinating cross-border logistics.", - icon: , - }, - { - id: "transporter", - label: "Transporter", - description: "Trucking company providing first/last-mile services.", - icon: , - }, - ]; + { + id: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + id: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, + { + id: "freight-forwarder-et", + label: "Freight Forwarder (Ethiopia)", + description: "Ethiopian freight forwarding company handling client cargo.", + icon: , + }, + { + id: "freight-forwarder-dj", + label: "FF Agent (Djibouti)", + description: "Djibouti-based agent coordinating cross-border logistics.", + icon: , + }, + { + id: "transporter", + label: "Transporter", + description: "Trucking company providing first/last-mile services.", + icon: , + }, +]; const USER_TYPE_LEFT_MAP: Record< OnboardingUserType, - { - badge: string; - title: string; - description: string; - } + { badge: string; title: string; description: string } > = { importer: { badge: "Importer Registration", @@ -107,12 +105,7 @@ const PREFLIGHT_LEFT = { "Freight Forwarders (Ethiopia & Djibouti)", "Transporters & Fleet Operators", ], - stats: { - label: "Active Customers", - value: "500+", - footer: "And growing", - progress: "w-[95%]", - }, + stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, }; const DOCUMENT_SETTING_CODE_MAP: Record = { @@ -127,9 +120,7 @@ export default function OnboardingPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [userType, setUserType] = useState(null); - const [documentFiles, setDocumentFiles] = useState< - Record - >({}); + const [documentFiles, setDocumentFiles] = useState>({}); const COMPANY_TYPE_MAP: Record = { importer: "customer", @@ -140,8 +131,7 @@ export default function OnboardingPage() { }; const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), + mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload), onSuccess: async (data) => { const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), @@ -149,100 +139,81 @@ export default function OnboardingPage() { if (hasFiles) { await companiesService.uploadDocuments(data.company.id, documentFiles); } - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); + await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey() }); }, }); if (!user) return null; const handleSubmit = (payload: CreateCompanyPayload) => { - const enriched: CreateCompanyPayload = { - ...payload, - companyType: COMPANY_TYPE_MAP[userType!], - }; + const enriched: CreateCompanyPayload = { ...payload, companyType: COMPANY_TYPE_MAP[userType!] }; createCompanyMutation.mutate(enriched); }; - const handleSelectType = (type: OnboardingUserType) => { - setUserType(type); - }; + const handleSelectType = (type: OnboardingUserType) => setUserType(type); + const handleBack = () => setUserType(null); - const handleBack = () => { - setUserType(null); - }; - - // Preflight: user type selection if (!userType) { return ( -
-
-

+ + + Select Account Type -

-

+ + Choose the account type that fits your role. -

-
+ + -
+ {USER_TYPE_CARDS.map((card) => ( - + + + {card.icon} + + + + {card.label} + + + {card.description} + + + + + ))} -
-
+ +
); } - // Render the appropriate form based on user type const leftConfig = USER_TYPE_LEFT_MAP[userType]; const leftProps = { ...leftConfig, features: userType === "transporter" - ? [ - "Vehicle & fleet registration", - "TIN & FAN verification", - "First-mile / Last-mile eligibility", - ] + ? ["Vehicle & fleet registration", "TIN & FAN verification", "First-mile / Last-mile eligibility"] : userType === "freight-forwarder-dj" - ? [ - "Company details", - "Representative information", - "Cross-border operations", - ] - : [ - "Company registration details", - "Contact and management personnel", - "Power of Attorney (optional)", - ], - stats: { - label: "Active Customers", - value: "500+", - footer: "And growing", - progress: "w-[95%]", - }, + ? ["Company details", "Representative information", "Cross-border operations"] + : ["Company registration details", "Contact and management personnel", "Power of Attorney (optional)"], + stats: { label: "Active Customers", value: "500+", footer: "And growing", progress: "w-[95%]" }, }; return ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 44ae45afa..87702bdd8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,20 +1,13 @@ -import { useState, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; -import { useForm } from "react-hook-form"; +import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRight, Check, LockKeyhole, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import { ArrowRight, Check, Eye, EyeOff, LockKeyhole, Loader2, X } from "lucide-react"; + import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; -import { cn } from "@/lib/utils"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, -} from "@edr/ui-common"; const passwordRequirements = [ { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, @@ -45,8 +38,6 @@ type FormData = z.infer; export default function SetPasswordPage() { const navigate = useNavigate(); const { setPassword } = useAuth(); - const [showPassword, setShowPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -105,112 +96,77 @@ export default function SetPasswordPage() { stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, }} > -
-
- -
-

Set Password

-

- Create a secure password for your account. -

-
+ + + + + + + Set Password + + + Create a secure password for your account. + + + {error && ( -
+ {error} -
+ )} -
- - - Password -
- - -
- -
+ + + + + {password && ( + + {requirements.map((req) => ( + + + {req.met ? : } + + + {req.label} + + + ))} + + )} + - {password && ( -
    - {requirements.map((req) => ( -
  • - {req.met ? ( - - ) : ( - - )} - {req.label} -
  • - ))} -
- )} + - - Confirm Password -
- - -
- -
-
- - + +
); 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 f8c8bc3c9..f85d11750 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,60 +1,33 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useForm } from "react-hook-form"; +import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ArrowRight, UserPlus, X } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; import { z } from "zod"; -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, - Field, - FieldLabel, - FieldError, - FieldGroup, -} from "@edr/ui-common"; 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), - }, + { 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"), + 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(), - }), + 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") @@ -76,8 +49,6 @@ 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, @@ -102,9 +73,7 @@ export default function SignupPage() { setError(null); setLoading(true); try { - const normalizedPhone = data.phone.startsWith("0") - ? data.phone.slice(1) - : data.phone; + const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone; const payload: SignupPayload = { email: data.email, username: data.email, @@ -120,7 +89,6 @@ export default function SignupPage() { const result = await signup(payload); if (result.success) { navigate("/portal"); - // navigate("/otp"); } else { setError(result.error.message); } @@ -131,6 +99,8 @@ export default function SignupPage() { } }; + const passwordValue = watch("password") ?? ""; + return ( -
-
- -
-

Create Account

-

- Register to access EDR Freight services. -

-
+ + + + + + + Create Account + + + Register to access EDR Freight services. + + + {error && ( -
+ {error} -
+ )} -
- -
- - First Name - - - - - - Last Name - - - -
- - Email Address - + + + - - + + + + - - Password -
- - -
- -
- {passwordRequirements.map((req) => { - const met = req.test(watch("password") ?? ""); - return ( -
- {met ? ( - - ) : ( - - )} - {req.label} -
- ); - })} -
-
+ + + {passwordValue.length > 0 && ( + + {passwordRequirements.map((req) => { + const met = req.test(passwordValue); + return ( + + + {met ? : } + + + {req.label} + + + ); + })} + + )} + - - Confirm Password -
- - -
- -
-
+ - - -

- Already have an account? -

+ + + Already have an account?{" "} + + +
); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx index eea5c0051..ef6f894fd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx @@ -1,42 +1,24 @@ -import { useState } from "react"; -import { useForm, Controller } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; +import { Box, Button, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; import { - ArrowRight, ArrowLeft, + ArrowRight, + CheckCircle2, ChevronLeft, Truck, - CheckCircle2, - Loader2, UploadCloud, } from "lucide-react"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SmartFileInput, -} from "@edr/ui-common"; -import { cn } from "@/lib/utils"; +import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -const TRUCK_TYPES = [ - "Casoni", - "Truck Trailer", - "High Bed", - "Low Bed", - "Others", -] as const; +const TRUCK_TYPES = ["Casoni", "Truck Trailer", "High Bed", "Low Bed", "Others"] as const; type TransporterStep = "vehicle" | "documents" | "confirm"; @@ -54,10 +36,7 @@ const transporterSchema = z .regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"), }) .superRefine((data, ctx) => { - if ( - data.truckType === "Casoni" && - (!data.plateNumber2 || data.plateNumber2.trim().length === 0) - ) { + if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["plateNumber2"], @@ -99,45 +78,25 @@ export default function TransporterForm({ }: { documentSettingCode: string; documentFiles?: Record; - onDocumentFilesChange?: ( - files: Record, - ) => void; + onDocumentFilesChange?: (files: Record) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("vehicle"); - const [internalFiles, setInternalFiles] = useState< - Record - >({}); + const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode }, - refetchOnMount: false, - }), + api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { - register, - handleSubmit, - trigger, - watch, - control, - formState: { errors }, - } = useForm({ + const { register, handleSubmit, trigger, watch, control, formState: { errors } } = useForm({ resolver: zodResolver(transporterSchema), defaultValues: { - tinNumber: "", - fanNumber: "", - truckType: "", - plateNumber: "", - plateNumber2: "", - vehicleModel: "", - yearOfManufacturing: "", + tinNumber: "", fanNumber: "", truckType: "", plateNumber: "", plateNumber2: "", vehicleModel: "", yearOfManufacturing: "", }, }); @@ -148,298 +107,220 @@ export default function TransporterForm({ const totalSteps = 3; const nextStep = async () => { - if (step === "documents") { - setStep("confirm"); - return; - } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const fields: (keyof FormData)[] = [ - "tinNumber", - "fanNumber", - "truckType", - "plateNumber", - "vehicleModel", - "yearOfManufacturing", - ]; + if (step === "documents") { setStep("confirm"); return; } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + const fields: (keyof FormData)[] = ["tinNumber", "fanNumber", "truckType", "plateNumber", "vehicleModel", "yearOfManufacturing"]; const isValid = await trigger(fields); if (!isValid) return; setStep("documents"); }; - const skipDocuments = () => { - setStep("confirm"); - }; + const skipDocuments = () => setStep("confirm"); const prevStep = () => { - if (step === "vehicle") { - onBack(); - } else if (step === "documents") { - setStep("vehicle"); - } else { - setStep("documents"); - } + if (step === "vehicle") onBack(); + else if (step === "documents") setStep("vehicle"); + else setStep("documents"); }; + const STEPS: { key: TransporterStep; icon: React.ReactNode }[] = [ + { key: "vehicle", icon: }, + { key: "documents", icon: }, + { key: "confirm", icon: }, + ]; + + const STEP_LABELS: Record = { + vehicle: `Step 1 of ${totalSteps} — Vehicle Information`, + documents: `Step 2 of ${totalSteps} — Upload Documents (Optional)`, + confirm: `Step 3 of ${totalSteps} — Review & Confirm`, + }; + + const stepOrder: TransporterStep[] = ["vehicle", "documents", "confirm"]; + const currentIdx = stepOrder.indexOf(step); + return ( <> -
- + -
-
- } - active={step === "vehicle"} - completed={step !== "vehicle"} - /> - } - active={step === "documents"} - completed={step === "confirm"} - /> - } - active={step === "confirm"} - completed={false} - /> -
-

- {step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`} - {step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`} - {step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`} -

-
+ + + {STEPS.map(({ key, icon }, i) => { + const done = i < currentIdx; + const active = i === currentIdx; + return done || active ? ( + + {done ? : icon} + + ) : ( + + {icon} + + ); + })} + -
e.preventDefault()} - className="flex flex-col gap-4" - > - {step === "vehicle" && ( - <> -
- - TIN Number (10 digits) - + {STEP_LABELS[step]} + + + + e.preventDefault()}> + + {step === "vehicle" && ( + <> + + - - - - - FAN Number (16 digits) - - - -
+ -
+ -

- Vehicle / Truck Information -

+ Vehicle / Truck Information - ( - - Truck Type - - - - )} - /> + ( + + - - - - {isCasoni && ( - - Plate Number (Trailer) - - - - )} - - {!isCasoni && ( - - Vehicle Model - - - - )} -
+ )} + -
- {isCasoni && ( - - Vehicle Model - + {isCasoni && ( + - - - )} - - - Year of Manufacturing - - - -
- - )} + + + )} - {step === "documents" && ( - <> - {loadingDocuments ? ( -
- -
- ) : !uploadSetting ? ( -

- No document requirements found for your account type. -

- ) : ( -
- -
- )} - - )} - - {step === "confirm" && ( -
-
-

- Review your registration -

-

- Confirm the details below before saving. -

-
- -
- - - - - {formValues.plateNumber2 && ( - - )} - - -
-
- )} - -
- - -
- {step === "documents" && ( - - )} - - -
-
+ + {step === "documents" && ( + + )} + + + + ); @@ -447,37 +328,13 @@ export default function TransporterForm({ function ReviewRow({ label, value }: { label: string; value?: string | null }) { return ( -
-
+ + {label} -
-
+ + {value?.trim() ? value : "Not provided"} -
-
- ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
+ + ); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index a09417c01..2a95e7c00 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -1,20 +1,14 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useForm } from "react-hook-form"; +import { Alert, Box, Button, Stack, Text, TextInput } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRight, MailCheck, RotateCw } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; import { z } from "zod"; -import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react"; + import { verificationCodeType } from "@/enums/verificationCodeType"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; -import { - Button, - Input, - Field, - FieldLabel, - FieldError, - FieldGroup, -} from "@edr/ui-common"; const otpSchema = z.object({ code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), @@ -96,106 +90,101 @@ export default function VerificationOtpPage() { stats: { label: "Verification Security", value: "99.9%", footer: "Protected", progress: "w-[99%]" }, }} > -
-
- -
-

OTP Verification

-

Enter the 6-digit code sent to:

-
-

{maskedPhone}

-
-
+ + + + + + + OTP Verification + + + Enter the 6-digit code sent to: + + + {maskedPhone} + + + {error && ( -
+ {error} -
+ )} {resentMessage && ( -
+ {resentMessage} -
+ )} -
- - - Verification Code - + + + -
- {errors.code ? ( - - ) : ( -

Enter the OTP sent to your phone

- )} - {otpValue.length}/6 -
-
-
+ + {otpValue.length}/6 + + - + - - -

- Didn't receive the code? -

+ + + Didn't receive the code?{" "} + + +
); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx deleted file mode 100644 index f81789c61..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ /dev/null @@ -1,1429 +0,0 @@ -import { useMemo, useRef, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertCircle, - Calendar, - MapPin, - Package, - StickyNote, - Train, - Weight, - Ship, - Truck, - Anchor, - FileText, - ShieldCheck, - AlertTriangle, - Info, - Layers, - CheckCircle2, - History, - ArrowRight, - ClipboardCheck, - CreditCard, - FileSignature, - PackageCheck, - LoaderCircle, - DollarSign, - Upload, - XCircle, - Building2, - FileUp, -} from "lucide-react"; - -import { format } from "date-fns"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import { api } from "@/services/api"; -import type { Freight } from "@edr/types"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Badge, - Button, - Separator, - Dialog, - DialogTrigger, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogClose, -} from "@edr/ui-common"; -import { cn } from "@/lib/utils"; -import useAuth from "@/hooks/useAuth"; - -const PROGRESS_STAGES = [ - { label: "Request", icon: FileText, statuses: ["DRAFT", "CHANGES_REQUESTED"] }, - { label: "Submitted", icon: ClipboardCheck, statuses: ["SUBMITTED", "PENDING_APPROVAL"] }, - { label: "Approved", icon: ShieldCheck, statuses: ["APPROVED_PENDING_SIGNATURE", "APPROVED", "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"] }, - { label: "In Transit", icon: Train, statuses: ["PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] }, - { label: "Complete", icon: PackageCheck, statuses: ["COMPLETED", "DELIVERED"] }, -]; - -const STATUS_MAP: Record< - string, - { title: string; description: string; color: string; stage: number } -> = { - DRAFT: { - title: "Drafting Request", - description: "Booking is being prepared and has not been submitted.", - color: "text-muted-foreground", - stage: 0, - }, - CHANGES_REQUESTED: { - title: "Changes Requested", - description: "Staff has requested changes. Please review and resubmit.", - color: "text-amber-600", - stage: 0, - }, - SUBMITTED: { - title: "Submitted", - description: "Your booking has been submitted for review.", - color: "text-primary", - stage: 1, - }, - PENDING_APPROVAL: { - title: "Pending Approval", - description: "Booking is in the approval process.", - color: "text-primary", - stage: 1, - }, - APPROVED_PENDING_SIGNATURE: { - title: "Awaiting Signature", - description: "Approved — pending contract signature.", - color: "text-primary", - stage: 2, - }, - APPROVED: { - title: "Approved", - description: "Booking has been fully approved.", - color: "text-primary", - stage: 2, - }, - CONTRACT_READY: { - title: "Contract Ready", - description: "Contract is available for review and signature.", - color: "text-primary", - stage: 2, - }, - SIGNED_CUSTOMER: { - title: "Customer Signed", - description: "Customer has signed. Awaiting staff signature.", - color: "text-primary", - stage: 2, - }, - FULLY_EXECUTED: { - title: "Fully Executed", - description: "Contract has been fully signed and executed.", - color: "text-primary", - stage: 2, - }, - PNR_GENERATED: { - title: "PNR Generated", - description: "Payment reference number generated.", - color: "text-primary", - stage: 3, - }, - PAYMENT_VERIFICATION_IN_PROGRESS: { - title: "Payment Verification", - description: "Payment is being verified.", - color: "text-primary", - stage: 3, - }, - PAID: { - title: "Paid", - description: "Payment has been confirmed.", - color: "text-primary", - stage: 3, - }, - IN_TRANSIT: { - title: "Cargo Moving", - description: "Shipment is currently moving through the rail network.", - color: "text-primary", - stage: 3, - }, - PENDING_CONSOLIDATION: { - title: "Pending Consolidation", - description: "Awaiting consolidation partner.", - color: "text-primary", - stage: 3, - }, - CONSOLIDATED: { - title: "Consolidated", - description: "Cargo has been consolidated with partner shipment.", - color: "text-primary", - stage: 3, - }, - COMPLETED: { - title: "Service Complete", - description: "Cargo delivered and service successfully terminated.", - color: "text-primary", - stage: 4, - }, - DELIVERED: { - title: "Service Complete", - description: "Cargo delivered and service successfully terminated.", - color: "text-primary", - stage: 4, - }, - REJECTED: { - title: "Rejected", - description: "This booking request has been rejected.", - color: "text-destructive", - stage: -1, - }, - CANCELLED: { - title: "Cancelled", - description: "This booking process has been terminated.", - color: "text-destructive", - stage: -1, - }, -}; - -const REQUIRED_DOC_FIELDS = [ - { key: "commercial_invoice", label: "Commercial Invoice" }, - { key: "packing_list", label: "Packing List" }, - { key: "certificate_of_origin", label: "Certificate of Origin" }, - { key: "letter_of_credit", label: "Letter of Credit / LC" }, -]; - -export default function BookingDetailPage() { - const { id } = useParams<{ id: string }>(); - const queryClient = useQueryClient(); - - const { - data: booking, - isLoading, - isError, - error, - } = useQuery( - api.bookings.get.queryOptions({ - input: { id: id! }, - enabled: !!id, - }), - ); - - const refetchBooking = () => { - queryClient.invalidateQueries({ - queryKey: api.bookings.get.queryKey({ id: id! }), - }); - }; - - if (isLoading) { - return ( -
-
- -

- Loading booking details… -

-
-
- ); - } - - if (isError) { - return ( -
- -
- -
-

- Failed to load booking -

-

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

-
-
- ); - } - - if (!booking) { - return ( -
- -
- -
-

- Booking not found -

-
-
- ); - } - - if (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") { - return ( - - ); - } - - return ; -} - -function DraftBookingView({ - booking, - onBookingUpdated, -}: { - booking: Freight.IBooking; - onBookingUpdated: () => void; -}) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const { customer } = useAuth(); - const fileInputRefs = useRef>({}); - const documentsRef = useRef(null); - - const [selectedFiles, setSelectedFiles] = useState< - Record - >({}); - const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - const [cancelReason, setCancelReason] = useState(""); - const [docError, setDocError] = useState(""); - - const anyFileSelected = Object.values(selectedFiles).some(Boolean); - - const uploadedCodes = useMemo( - () => new Set(booking.files?.map((f) => f.code) ?? []), - [booking.files], - ); - - const { data: generatedPricing } = useQuery({ - ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), - enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, - }); - - const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; - - const uploadMutation = useMutation({ - mutationFn: (files: Record) => - api.bookings.uploadDocuments.call({ id: booking.id, files }), - onSuccess: () => { - setSelectedFiles({}); - setDocError(""); - onBookingUpdated(); - }, - }); - - const submitMutation = useMutation({ - mutationFn: () => api.bookings.submit.call({ id: booking.id }), - onSuccess: () => { - onBookingUpdated(); - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - }, - }); - - const cancelMutation = useMutation({ - mutationFn: (reason: string) => - api.bookings.cancel.call({ id: booking.id, reason }), - onSuccess: () => { - setCancelDialogOpen(false); - onBookingUpdated(); - }, - }); - - function handleFileSelect(key: string, file: File | null) { - setSelectedFiles((prev) => ({ ...prev, [key]: file })); - } - - function handleUploadAll() { - const filesToUpload: Record = {}; - for (const doc of REQUIRED_DOC_FIELDS) { - if (selectedFiles[doc.key]) { - filesToUpload[doc.key] = selectedFiles[doc.key]!; - } - } - if (Object.keys(filesToUpload).length === 0) return; - uploadMutation.mutate(filesToUpload); - } - - function handleCancel() { - const reason = cancelReason.trim() || "Cancelled by customer"; - cancelMutation.mutate(reason); - } - - function handleSubmitRequest() { - const missing = REQUIRED_DOC_FIELDS.filter( - (doc) => !uploadedCodes.has(doc.key), - ); - if (missing.length > 0) { - setDocError("Please upload all required documents before submitting."); - documentsRef.current?.scrollIntoView({ behavior: "smooth" }); - return; - } - submitMutation.mutate(); - } - - const companyName = (customer as any)?.company?.name ?? "—"; - const companyTin = (customer as any)?.company?.tin ?? "—"; - const contactName = (customer as any)?.profile - ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || - "—" - : "—"; - const contactEmail = (customer as any)?.profile?.email ?? "—"; - - return ( -
-
- - - - -
-
- -
-
-
-

- {booking.reference} -

- -
-

- {booking.status === "CHANGES_REQUESTED" - ? "Staff has requested changes. Please review, update, and resubmit." - : "Complete the steps below to submit your booking request."} -

-
- -
- - -
-
-
-
- - {booking.status === "CHANGES_REQUESTED" && booking.latestChangeRequestNote && ( -
- -
-

Changes Requested by Staff

-

{booking.latestChangeRequestNote}

-
-
- )} - - {uploadMutation.isError && ( -
- -
-

Document upload failed

-

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

-
-
- )} - - {submitMutation.isError && ( -
- -
-

Submission failed

-

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

-
-
- )} - - {cancelMutation.isError && ( -
- -
-

Cancel failed

-

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

-
-
- )} - - - - - - Pricing Estimation - - - Price estimate based on your booking details. - - - - {pricing ? ( -
-
- - - - - - - - - {pricing.lineItems.map((item, i) => ( - - - - - ))} - - - - - -
Description - Amount -
- {item.description} - - {item.amount.toLocaleString()} {item.currency} -
- Total Estimated Cost - - {pricing.totalAmount.toLocaleString()}{" "} - {pricing.currency} -
-
-
- ) : ( -

- Pricing will be calculated after submission. -

- )} -
-
- - - - - - Required Documents - - - Provide the necessary documents for this booking. Some information - is pre-filled from your company profile. - - - - {docError && ( -
- -

{docError}

-
- )} -
-

- - Company Information (from profile) -

-
- - - - -
-

- To update your company information, go to{" "} - - . -

-
- - - -
-

- - Upload Booking Documents -

-
- {REQUIRED_DOC_FIELDS.map((doc) => { - const isUploaded = uploadedCodes.has(doc.key); - return ( -
- -
- {isUploaded ? ( - - - Uploaded - - ) : ( - <> - { - fileInputRefs.current[doc.key] = el; - }} - type="file" - accept=".pdf,.jpg,.jpeg,.png" - className="hidden" - onChange={(e) => { - handleFileSelect( - doc.key, - e.target.files?.[0] ?? null, - ); - }} - /> - - {selectedFiles[doc.key] && ( - - )} - - )} -
-
- ); - })} -
- -
- - {uploadMutation.isSuccess && ( -

- - Documents uploaded successfully -

- )} -
-
-
-
- - - - - - Cancel Booking - - - If you no longer need this booking, you can cancel it. - - - -

- Cancelling will terminate this booking request and cannot be - undone. -

- - - - - - - Cancel Booking - - Are you sure you want to cancel this booking? This action - cannot be undone. - - -
- - setCancelReason(e.target.value)} - autoFocus - /> -
- - - - - - -
-
-
-
-
-
- ); -} - -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 ( -
-
- - - - -
-
- -
-
-
-

- {booking.reference} -

- -
-
- - - {format( - new Date(booking.scheduledDate ?? booking.createdAt), - "MMM d, yyyy HH:mm", - )} - -
-
- {normalizedStatus === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID" && ( - - )} -
-
-
- - {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}) - - - - - - - )} - - - - - - Booking Status Lifecycle - - - Track the journey from request to completion - - - -
-
-
= 0 - ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` - : "0%", - }} - /> -
- - {PROGRESS_STAGES.map((stage, idx) => { - const isCompleted = idx < currentStageIndex; - const isActive = idx === currentStageIndex; - - return ( -
-
- {isCompleted ? ( - - ) : ( - - )} -
- - {stage.label} - -
- ); - })} -
- -
-
- {normalizedStatus === "CANCELLED" ? ( - - ) : ( - - )} -
-
-

- {statusConfig.title} -

-

- {statusConfig.description} -

-
- {normalizedStatus !== "CANCELLED" && - normalizedStatus !== "DELIVERED" && ( -
-
-

- Est. Waiting -

-

- 1-2 Working Days -

-
- -
- )} -
- - - -
-
- - - - - Route & Service - - - -
- } - /> -
-
- - -
- - Rail - -
- } - /> -
- -
- } - label="Service" - value={ - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail & Forwarding" - : "Rail Only" - } - /> - } - label="Return" - value={ - booking.equipmentReturn === "WITH_RETURN" - ? "With Return" - : "Without Return" - } - /> - } - label="Trade" - value={ - booking.tradeDirection === "IMPORT" ? "Import" : "Export" - } - /> -
-
-
- - - - - - Mile Services - - - -
-

- First Mile -

- -
-
-

- Last Mile -

-

- {booking.lastMileEnabled && booking.lastMileDeliveryAddress - ? booking.lastMileDeliveryAddress - : "Not requested"} -

-
-
-
- - - - - - Cargo Specifications - - - -
- } - label="Freight Type" - value={ - booking.freightType === "BULK" ? "Bulk" : "Break Bulk" - } - /> - } - label="Weight (VGM)" - value={`${booking.cargoTotalWeightVgm} Tons`} - /> - } - label="Currency" - value={booking.paymentCurrency} - /> -
- - {booking.containers && booking.containers.length > 0 && ( - <> - -
-

- Load Details -

-
- - - - - - - - - - {booking.containers.map((c, i) => ( - - - - - - ))} - -
Type - Quantity - - VGM (Tons) -
- {c.type} - - {c.qty} Units - - {c.vgm}t -
-
-
- - )} -
-
-
- -
- - - - - Contract Info - - - - - - -
- - Hazardous: {booking.isHazardous ? "Yes" : "No"} - - - Refrigerated: {booking.isRefrigerated ? "Yes" : "No"} - -
-
-
- - - - Additional Info - - - {booking.freightSubtype && ( -
-

- Cargo Description -

-

- "{booking.freightSubtype}" -

-
- )} - {booking.financialTerms && ( - <> - -
-

- Financial Terms -

-
-

- - {booking.financialTerms} -

-
-
- - )} - {!booking.freightSubtype && !booking.financialTerms && ( -

- No additional information provided. -

- )} -
-
-
-
-
-
- ); -} - -function renderContractCard( - booking: Freight.IBooking, - navigate: ReturnType, - payMutation: { mutate: () => void; isPending: boolean }, -) { - const s = booking.status; - if ( - s !== "APPROVED_PENDING_SIGNATURE" && - s !== "CONTRACT_READY" && - s !== "SIGNED_CUSTOMER" && - s !== "FULLY_EXECUTED" - ) { - return null; - } - - const config: Record< - string, - { title: string; description: string; buttonLabel?: string } - > = { - APPROVED_PENDING_SIGNATURE: { - title: "Contract being prepared", - description: - "Your booking has been approved. The contract is being generated and will be available shortly.", - }, - CONTRACT_READY: { - title: "Contract ready for signature", - description: - "Review the agreement and apply your digital signature.", - buttonLabel: "View & sign contract", - }, - SIGNED_CUSTOMER: { - title: "You have signed the contract", - description: - "Your signature has been submitted. Awaiting staff signature to finalize.", - buttonLabel: "View contract", - }, - FULLY_EXECUTED: { - title: "Contract fully executed", - description: - "The contract has been fully signed and executed by all parties.", - buttonLabel: "View contract", - }, - }; - - const c = config[s]; - - return ( - - -
-

{c.title}

-

{c.description}

-
- {c.buttonLabel && ( - - )} -
-
- ); -} - -function RouteEndpoint({ - label, - station, - icon, -}: { - label: string; - station: string; - icon: React.ReactNode; -}) { - return ( -
-
- {icon &&
{icon}
} -
-
-

- {label} -

-

{station}

-
-
- ); -} - -function InfoItem({ - icon, - label, - value, -}: { - icon?: React.ReactNode; - label: string; - value?: string | number | null; -}) { - return ( -
- {icon && ( -
- {icon} -
- )} -
-

- {label} -

-

{value ?? "—"}

-
-
- ); -} - -function StatusBadge({ status }: { status: string }) { - const statusColors: Record = { - DRAFT: "bg-muted text-muted-foreground border-border", - CHANGES_REQUESTED: "bg-amber-50 text-amber-700 border-amber-200", - SUBMITTED: "bg-primary/10 text-primary border-primary/20", - PENDING_APPROVAL: "bg-primary/10 text-primary border-primary/20", - APPROVED_PENDING_SIGNATURE: "bg-primary/10 text-primary border-primary/20", - APPROVED: "bg-primary/10 text-primary border-primary/20", - CONTRACT_READY: "bg-primary/10 text-primary border-primary/20", - SIGNED_CUSTOMER: "bg-primary/10 text-primary border-primary/20", - FULLY_EXECUTED: "bg-primary/10 text-primary border-primary/20", - PNR_GENERATED: "bg-primary/10 text-primary border-primary/20", - PAYMENT_VERIFICATION_IN_PROGRESS: "bg-primary/10 text-primary border-primary/20", - PAID: "bg-primary/10 text-primary border-primary/20", - CONFIRMED: "bg-primary/10 text-primary border-primary/20", - IN_TRANSIT: "bg-primary/10 text-primary border-primary/20", - PENDING_CONSOLIDATION: "bg-primary/10 text-primary border-primary/20", - CONSOLIDATED: "bg-primary/10 text-primary border-primary/20", - COMPLETED: "bg-muted text-foreground border-border", - DELIVERED: "bg-muted text-foreground border-border", - REJECTED: "bg-destructive/10 text-destructive border-destructive/20", - CANCELLED: "bg-destructive/10 text-destructive border-destructive/20", - }; - - return ( - - {status.replace(/_/g, " ")} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx new file mode 100644 index 000000000..e69a024cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -0,0 +1,423 @@ +import { + ActionIcon, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + Download, + Pencil, + Send, + Upload, + X, + XCircle, +} from "lucide-react"; + +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { REQUIRED_DOC_FIELDS } from "./constants"; +import { CardTitle, PageShell, SectionCard } from "./components/layout"; +import { CountChip, DocRow, IconSquare } from "./components/Documents"; +import { EstimateCard } from "./components/pricing"; +import { HeaderButton, PageHeader } from "./components/PageHeader"; +import { + ActionRequiredBanner, + MutationErrors, + NoticeBanner, +} from "./components/Notices"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { StepGhostButton, StepLine } from "./components/Steps"; +import { SupportCard } from "./components/SupportCard"; +import { BodyGrid } from "./components/layout"; + +export function DraftBookingView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const fileInputRefs = useRef>({}); + const documentsRef = useRef(null); + + const [selectedFiles, setSelectedFiles] = useState< + Record + >({}); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + const [docError, setDocError] = useState(""); + + const anyFileSelected = Object.values(selectedFiles).some(Boolean); + const uploadedCodes = useMemo( + () => new Set(booking.files?.map((f) => f.code) ?? []), + [booking.files], + ); + const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) => + uploadedCodes.has(d.key), + ).length; + const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length; + + const { data: generatedPricing } = useQuery({ + ...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }), + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, + }); + const pricing = booking.pricingBreakdown ?? generatedPricing ?? null; + + const uploadMutation = useMutation({ + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), + onSuccess: () => { + setSelectedFiles({}); + setDocError(""); + onBookingUpdated(); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: () => { + onBookingUpdated(); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + setCancelDialogOpen(false); + onBookingUpdated(); + }, + }); + + function handleFileSelect(key: string, file: File | null) { + setSelectedFiles((prev) => ({ ...prev, [key]: file })); + } + + function handleUploadAll() { + const filesToUpload: Record = {}; + for (const doc of REQUIRED_DOC_FIELDS) { + if (selectedFiles[doc.key]) + filesToUpload[doc.key] = selectedFiles[doc.key]!; + } + if (Object.keys(filesToUpload).length === 0) return; + uploadMutation.mutate(filesToUpload); + } + + function handleSubmitRequest() { + const missing = REQUIRED_DOC_FIELDS.filter( + (doc) => !uploadedCodes.has(doc.key), + ); + if (missing.length > 0) { + setDocError("Please upload all required documents before submitting."); + documentsRef.current?.scrollIntoView({ behavior: "smooth" }); + return; + } + submitMutation.mutate(); + } + + const completeStep = allDocsUploaded ? 3 : 2; + + return ( + + } + label="Continue editing" + onClick={() => navigate(`/bookings/${booking.id}/edit`)} + /> + } + menuActions={{ + onCancel: () => setCancelDialogOpen(true), + onSupport: () => navigate("/support"), + }} + /> + + + + + {booking.status === "CHANGES_REQUESTED" && + booking.latestChangeRequestNote ? ( + navigate(`/bookings/${booking.id}/edit`)} + > + {booking.latestChangeRequestNote} + + ) : undefined} + + + + {/* Complete your booking */} + + + Complete your booking + + Step {completeStep} of 3 + + + + + } + onClick={() => + documentsRef.current?.scrollIntoView({ + behavior: "smooth", + }) + } + > + Add documents + + } + /> + + + + + + + + {/* Documents (uploadable) */} + + + + Documents + + + + + {docError && ( + } + className="mb-3" + > + {docError} + + )} + + + {REQUIRED_DOC_FIELDS.map((doc, i) => { + const isUploaded = uploadedCodes.has(doc.key); + const selected = selectedFiles[doc.key]; + const file = booking.files?.find((f) => f.code === doc.key); + return ( + } + /> + ) : ( + <> + { + fileInputRefs.current[doc.key] = el; + }} + type="file" + accept=".pdf,.jpg,.jpeg,.png" + style={{ display: "none" }} + onChange={(e) => + handleFileSelect( + doc.key, + e.target.files?.[0] ?? null, + ) + } + /> + + + {selected && ( + handleFileSelect(doc.key, null)} + style={{ color: "#C0392B" }} + > + + + )} + + + ) + } + /> + ); + })} + + + {anyFileSelected && ( + + )} + + + } + right={ + <> + + + setCancelDialogOpen(true)} /> + + } + /> + + setCancelDialogOpen(false)} + title={Cancel booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference} + ? This action cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx new file mode 100644 index 000000000..60efb704e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -0,0 +1,129 @@ +import { Box, Group, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { CreditCard, Download } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { ActivityCard } from "./components/ActivityCard"; +import { ContractCard } from "./components/ContractCard"; +import { DocRow, IconSquare } from "./components/Documents"; +import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; +import { CancelledBanner } from "./components/Notices"; +import { HeaderButton, PageHeader } from "./components/PageHeader"; +import { PaymentCard } from "./components/pricing"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { SupportCard } from "./components/SupportCard"; +import { fmtDate, isNegative } from "./utils"; + +export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { + const navigate = useNavigate(); + const status = booking.status as string; + + const payMutation = useMutation({ + mutationFn: () => api.bookings.pay.call({ id: booking.id }), + onSuccess: (data) => { + if (data.redirectUrl) window.location.href = data.redirectUrl; + }, + }); + + const pricing = booking.pricingBreakdown; + const canPay = + status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID"; + + return ( + + } + label={payMutation.isPending ? "Processing…" : "Pay now"} + onClick={() => payMutation.mutate()} + disabled={payMutation.isPending} + /> + ) + } + menuActions={{ + onViewContract: booking.signedByCeoAt ? () => {} : undefined, + onRebook: () => navigate("/bookings/new"), + onSupport: () => navigate("/support"), + }} + /> + + {isNegative(status) ? ( + navigate("/bookings/new")} + /> + ) : ( + + )} + + + + + + + {booking.files && booking.files.length > 0 && ( + + + Documents + + {booking.files.length} files + + + + {booking.files.map((file, i) => ( + } + /> + } + /> + ))} + + + )} + + + + } + right={ + <> + + + + + } + /> + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx new file mode 100644 index 000000000..c08b6d5f5 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ActivityCard.tsx @@ -0,0 +1,110 @@ +import { Box, Group, Text } from "@mantine/core"; + +import type { Freight } from "@edr/types"; + +import { fmtDate } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +export function ActivityCard({ booking }: { booking: Freight.IBooking }) { + const events = [ + booking.signedByCeoAt && { + at: booking.signedByCeoAt, + title: "Contract fully executed", + note: "Signed by all parties", + }, + booking.signedByDirectorAt && { + at: booking.signedByDirectorAt, + title: "Director signed contract", + note: "Awaiting final signature", + }, + booking.approvedByStaffAt && { + at: booking.approvedByStaffAt, + title: "Booking approved", + note: "Cleared by EDR staff", + }, + { + at: booking.createdAt, + title: "Booking created", + note: "Request drafted by customer", + }, + ].filter(Boolean) as { at: string; title: string; note: string }[]; + + if (events.length === 0) return null; + + return ( + + + Activity + + Full history + + + + {events.map((e, i) => { + const first = i === 0; + const lastItem = i === events.length - 1; + return ( + + + + {!lastItem && ( + + )} + + + + + {e.title} + + {first && ( + + Current + + )} + + + {fmtDate(e.at)} · {e.note} + + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx new file mode 100644 index 000000000..aa0abb384 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractCard.tsx @@ -0,0 +1,118 @@ +import { Box, Button, Group, Paper, Text } from "@mantine/core"; +import { FileSignature } from "lucide-react"; +import type { useNavigate } from "react-router-dom"; + +import type { Freight } from "@edr/types"; + +const CONTRACT_CONFIG: Record< + string, + { + title: string; + description: string; + buttonLabel?: string; + } +> = { + APPROVED_PENDING_SIGNATURE: { + title: "Your contract is being prepared.", + description: + "Your booking has been approved. The contract will be available shortly.", + }, + CONTRACT_READY: { + title: "Review and sign your contract to proceed.", + description: + "Once all parties sign, we'll issue your payment reference (PNR) and schedule the cargo.", + buttonLabel: "Review & Sign", + }, + SIGNED_CUSTOMER: { + title: "You have signed the contract.", + description: + "Your signature has been submitted. Awaiting the final staff signature.", + buttonLabel: "View contract", + }, + FULLY_EXECUTED: { + title: "Contract fully executed — proceed to payment.", + description: + "The contract has been signed by all parties. You can now proceed to payment.", + buttonLabel: "View contract", + }, +}; + +export function ContractCard({ + booking, + navigate, +}: { + booking: Freight.IBooking; + navigate: ReturnType; +}) { + const c = CONTRACT_CONFIG[booking.status as string]; + if (!c) return null; + + return ( + + + + + + + + + What's next + + + {c.title} + + + {c.description} + + + + {c.buttonLabel && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx new file mode 100644 index 000000000..bf9c2f894 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx @@ -0,0 +1,174 @@ +import { Box, Group, Text } from "@mantine/core"; +import { CheckCircle2, FileText, MinusCircle } from "lucide-react"; +import type { ReactNode } from "react"; + +type DocStatus = "verified" | "ready" | "missing"; + +const STATUS_PILL: Record< + DocStatus, + { label: string; bg: string; color: string; border?: string; icon?: ReactNode } +> = { + verified: { + label: "Uploaded", + bg: "#ECF6F1", + color: "#0A6F4D", + border: "#CDEBDD", + icon: , + }, + ready: { + label: "Ready", + bg: "#EAF1FB", + color: "#2E5B96", + }, + missing: { + label: "Not added", + bg: "#F1F4F7", + color: "#9AA8B5", + icon: , + }, +}; + +export function DocRow({ + title, + meta, + status, + action, + last, +}: { + title: string; + meta: string; + status: DocStatus; + action: ReactNode; + last?: boolean; +}) { + const missing = status === "missing"; + const tileBg = status === "ready" ? "#EAF1FB" : "#F1F4F7"; + const tileFg = + status === "ready" ? "#2E5B96" : missing ? "#9AA8B5" : "#475569"; + const pill = STATUS_PILL[status]; + + return ( + + + + + + + {title} + + + {meta} + + + + {pill.icon} + {pill.label} + + {action} + + ); +} + +export function IconSquare({ + icon, + href, +}: { + icon: ReactNode; + href?: string | null; +}) { + const style: React.CSSProperties = { + flexShrink: 0, + width: 34, + height: 34, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 8, + border: "1px solid #E6ECF2", + color: "#6B7C8E", + }; + if (href) { + return ( + + {icon} + + ); + } + return ( + + {icon} + + ); +} + +export function CountChip({ + uploaded, + total, +}: { + uploaded: number; + total: number; +}) { + const done = uploaded === total; + return ( + + {done && } + {uploaded} of {total} added + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx new file mode 100644 index 000000000..c98cb726d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Notices.tsx @@ -0,0 +1,212 @@ +import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react"; +import type { ReactNode } from "react"; + +export function NoticeBanner({ + tone, + icon, + title, + children, + className, +}: { + tone: "amber" | "red"; + icon: ReactNode; + title?: string; + children: ReactNode; + className?: string; +}) { + const palette = + tone === "amber" + ? { border: "#F6E2BC", bg: "#FDF3E0", color: "#9A5B00" } + : { border: "#F3C8C1", bg: "#FBEAE7", color: "#A93226" }; + + return ( +
+ {icon} +
+ {title && ( + + {title} + + )} + + {children} + +
+
+ ); +} + +export function ActionRequiredBanner({ + title, + children, + onAction, +}: { + title: string; + children: ReactNode; + onAction?: () => void; +}) { + return ( +
+
+
+ +
+
+ + Action required · You + + + {title} + + + {children} + +
+
+ {onAction && ( + + )} +
+ ); +} + +export function CancelledBanner({ + pillLabel = "Cancelled", + title, + subtitle, + reason, + onRebook, +}: { + pillLabel?: string; + title: string; + subtitle: string; + reason?: string | null; + onRebook?: () => void; +}) { + return ( + + + + +
+ +
+ + + {pillLabel} + + + {title} + + + {subtitle} + + +
+ {onRebook && ( + + )} +
+ {reason && ( +
+ +
+ + Reason for cancellation + + + {reason} + +
+
+ )} +
+
+ ); +} + +export function MutationErrors({ + mutations, +}: { + mutations: { isError: boolean; error: unknown }[]; +}) { + const errored = mutations.filter((m) => m.isError); + if (errored.length === 0) return null; + return ( + <> + {errored.map((m, i) => ( + } + title="Something went wrong" + > + {m.error instanceof Error + ? m.error.message + : "An unexpected error occurred."} + + ))} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx new file mode 100644 index 000000000..6958a12dc --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -0,0 +1,178 @@ +import { ActionIcon, Button, Group, Menu, Stack, Text } from "@mantine/core"; +import { + ArrowDownLeft, + ArrowUpRight, + Edit2, + FileText, + HelpCircle, + MoreHorizontal, + RefreshCw, + XCircle, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import type { Freight } from "@edr/types"; + +import { bookingSubtitle, isDraftLike, isNegative } from "../utils"; + +export interface PageHeaderMenuActions { + onViewContract?: () => void; + onCancel?: () => void; + onEdit?: () => void; + onSupport?: () => void; + onRebook?: () => void; +} + +export function PageHeader({ + booking, + actions, + menuActions, +}: { + booking: Freight.IBooking; + actions?: ReactNode; + menuActions?: PageHeaderMenuActions; +}) { + const status = booking.status as string; + const negative = isNegative(status); + const draft = isDraftLike(status); + + const dotColor = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371"; + const pillBg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; + const pillBorder = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; + const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; + const isExport = booking.tradeDirection === "EXPORT"; + + const hasMenu = menuActions && Object.values(menuActions).some(Boolean); + + return ( + + + + + {booking.reference} + + + + + {status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())} + + + + {isExport ? : } + {isExport ? "Export" : "Import"} + + + + {bookingSubtitle(booking)} + + + + + {actions} + {hasMenu && ( + + + + + + + + {menuActions!.onViewContract && ( + } + onClick={menuActions!.onViewContract} + > + View contract + + )} + {menuActions!.onEdit && ( + } + onClick={menuActions!.onEdit} + > + Edit + + )} + {menuActions!.onSupport && ( + } + onClick={menuActions!.onSupport} + > + Contact customer support + + )} + {menuActions!.onRebook && ( + } + onClick={menuActions!.onRebook} + > + Rebook similar schedule + + )} + {menuActions!.onCancel && ( + <> + + } + onClick={menuActions!.onCancel} + > + Cancel booking + + + )} + + + )} + + + ); +} + +export function HeaderButton({ + label, + icon, + onClick, + dark, + green, + disabled, +}: { + label: string; + icon: ReactNode; + onClick?: () => void; + dark?: boolean; + green?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx new file mode 100644 index 000000000..5f64d10a1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -0,0 +1,128 @@ +import { Box, Group, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +import type { Freight } from "@edr/types"; + +import { fmtDate, isDraftLike, isNegative } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +type Row = { label: string; value: ReactNode; muted?: boolean }; + +function StatusPill({ status }: { status: string }) { + const negative = isNegative(status); + const draft = isDraftLike(status); + const dot = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371"; + const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; + const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; + const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; + const label = status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + + return ( + + + {label} + + ); +} + +export function ScheduleCard({ + booking, + title, + consignment, +}: { + booking: Freight.IBooking; + title: string; + consignment?: boolean; +}) { + const service = + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail + Forwarding" + : "Rail only"; + const equipmentReturn = + booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; + const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed"; + const assignedTrain: Row = { + label: "Assigned train", + value: booking.trainId ?? "Not yet assigned", + muted: !booking.trainId, + }; + + const statusRow: Row = { + label: "Status", + value: , + }; + + const rows: Row[] = consignment + ? [ + { label: "Consignment ID", value: booking.reference }, + { label: "Service", value: service }, + { label: "Equipment return", value: equipmentReturn }, + assignedTrain, + { label: "Scheduled", value: fmtDate(booking.scheduledDate) }, + { label: "Consolidation", value: consolidation }, + ] + : [ + statusRow, + { label: "Service", value: service }, + { label: "Equipment return", value: equipmentReturn }, + { label: "Proposed date", value: fmtDate(booking.scheduledDate) }, + assignedTrain, + { label: "Consolidation", value: consolidation }, + ]; + + return ( + + + {title} + + + {rows.map((r, i) => ( + + + {r.label} + + + {r.value} + + + ))} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx new file mode 100644 index 000000000..259a5c113 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -0,0 +1,106 @@ +import { Box, Group, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { containerSummary, fmtDate, yardLabel } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) { + const rows: [string, string][][] = [ + [ + ["Origin yard", yardLabel(booking.originYard)], + ["Destination yard", yardLabel(booking.destinationYard)], + ], + [ + ["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"], + ["Commodity", booking.freightSubtype || "—"], + ], + [ + ["Containers / load", containerSummary(booking)], + [ + "Total weight (VGM)", + booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—", + ], + ], + [ + [ + "Service type", + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail + Forwarding" + : "Rail only", + ], + [ + "Equipment return", + booking.equipmentReturn === "WITH_RETURN" + ? "With return" + : "Without return", + ], + ], + [ + [ + "Trade direction", + booking.tradeDirection === "IMPORT" ? "Import" : "Export", + ], + ["Scheduled date", fmtDate(booking.scheduledDate)], + ], + [ + ["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"], + ["Assigned train", booking.trainId ?? "Not yet assigned"], + ], + ]; + + return ( + + + Shipment Details + + + {booking.contractType === "RENEWAL" + ? "Renewal contract" + : "New contract"} + + + + {rows.map((pair, i) => ( + + {pair.map(([k, v]) => ( + + + {k} + + + {v} + + + ))} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx new file mode 100644 index 000000000..95e4cdf78 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -0,0 +1,208 @@ +import { Box, Group, Text } from "@mantine/core"; +import { AlertTriangle, Check, FileText, History } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { PROGRESS_STAGES, STATUS_MAP } from "../constants"; +import { fmtDate, isDraftLike, isNegative } from "../utils"; +import { SectionCard } from "./layout"; + +export function StatusHero({ + booking, + children, +}: { + booking: Freight.IBooking; + children?: React.ReactNode; +}) { + const status = booking.status as string; + const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; + const negative = isNegative(status); + const draft = isDraftLike(status); + + const tone: "green" | "slate" | "red" = negative + ? "red" + : draft + ? "slate" + : "green"; + const tileBg = + tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1"; + const tileFg = + tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371"; + const HeroIcon = negative + ? AlertTriangle + : draft + ? FileText + : (PROGRESS_STAGES[cfg.stage]?.icon ?? History); + + const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled"; + const chipValue = fmtDate( + draft || negative ? booking.updatedAt : booking.scheduledDate, + ); + + return ( + + + +
+ +
+ + + {cfg.title} + + + {cfg.description} + + +
+ + + + + {chipLabel} + + + {chipValue} + + + +
+ + + + {children ?? ( + + )} +
+ ); +} + +function ProgressTracker({ + current, + tone = "green", + negative, +}: { + current: number; + tone?: "green" | "ink"; + negative?: boolean; +}) { + const last = PROGRESS_STAGES.length - 1; + const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; + const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; + const activeSub = tone === "ink" ? "#475569" : "#0A6F4D"; + + return ( + /* Scrollable on mobile so 5 stages never overflow */ + +
+ {PROGRESS_STAGES.map((stage, idx) => { + const state = + idx < current ? "done" : idx === current ? "active" : "idle"; + const Icon = stage.icon; + const reachedLeft = current >= idx && current >= 0; + const reachedRight = current > idx && current >= 0; + + const circleBg = + state === "idle" + ? "#EEF2F6" + : state === "active" + ? activeFill + : "#0EA371"; + const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined; + const circleShadow = + state === "active" ? `0 0 0 4px ${activeRing}` : undefined; + + return ( +
+
+ {/* left connector */} +
+ {/* stage circle */} +
+ {state === "done" ? ( + + ) : state === "active" ? ( + + ) : null} +
+ {/* right connector */} +
+
+ + {stage.label} + + + {state === "done" + ? "Completed" + : state === "active" + ? negative + ? "Stopped" + : "In progress" + : "Pending"} + +
+ ); + })} +
+ + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx new file mode 100644 index 000000000..8ad1646c3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Steps.tsx @@ -0,0 +1,122 @@ +import { Box, Button, Group, Text } from "@mantine/core"; +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; + +function StepCircle({ + index, + done, + active, +}: { + index: number; + done?: boolean; + active?: boolean; +}) { + const style: React.CSSProperties = done + ? { backgroundColor: "#0EA371", color: "#fff" } + : active + ? { backgroundColor: "#0C1A2B", color: "#fff" } + : { backgroundColor: "#EEF2F6", color: "#9AA8B5" }; + + return ( + + {done ? : index} + + ); +} + +export function StepLine({ + index, + title, + desc, + action, + done, + active, +}: { + index: number; + title: string; + desc?: string; + action?: ReactNode; + done?: boolean; + active?: boolean; +}) { + if (active) { + return ( + + + + + + {title} + + {desc && ( + + {desc} + + )} + + + {action} + + ); + } + + return ( + + + + {title} + + + ); +} + +export function StepGhostButton({ + children, + onClick, + icon, +}: { + children: ReactNode; + onClick?: () => void; + icon?: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx new file mode 100644 index 000000000..02fafab17 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/SupportCard.tsx @@ -0,0 +1,61 @@ +import { Box, Button, Group, Paper, Text } from "@mantine/core"; +import { FileText, MessageSquare, XCircle } from "lucide-react"; + +export function SupportCard({ onCancel }: { onCancel?: () => void }) { + return ( + + + + + + + + Need help? + + + EDR operations team + + + + + Questions about this shipment, documents, or delivery? Our operations + team can help. + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx new file mode 100644 index 000000000..9fda89648 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/layout.tsx @@ -0,0 +1,55 @@ +import { Box, Flex, Paper, Stack, Text, type PaperProps } from "@mantine/core"; +import type { ReactNode, Ref } from "react"; + +export function PageShell({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +export function BodyGrid({ left, right }: { left: ReactNode; right: ReactNode }) { + return ( + + {/* Left column: capped at 700px on tablet so it doesn't bleed edge-to-edge */} + + {left} + + + {right} + + + ); +} + +interface SectionCardProps extends PaperProps { + children: ReactNode; + ref?: Ref; +} + +export function SectionCard({ children, ref, ...props }: SectionCardProps) { + return ( + + {children} + + ); +} + +export function CardTitle({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx new file mode 100644 index 000000000..ec6c10f02 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -0,0 +1,188 @@ +import { Box, Button, Group, Stack, Text } from "@mantine/core"; +import { CheckCircle2, Clock, FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; + +import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +function LineItems({ pricing }: { pricing: Pricing }) { + const items = priceLineItems(pricing); + if (items.length === 0) return null; + return ( + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + ); +} + +const Divider = () => ; + +export function EstimateCard({ + pricing, + title, + chip, +}: { + pricing: Pricing; + title: string; + chip: string; +}) { + const hasItems = priceLineItems(pricing).length > 0; + return ( + + + {title} + + + {chip} + + + + + + {priceTotal(pricing)} + + + est. + + + + A firm price is confirmed after EDR reviews your booking. + + + {hasItems && ( + <> + + + + + Estimated total + + + {priceTotal(pricing)} + + + + )} + + ); +} + +export function PaymentCard({ + booking, + pricing, +}: { + booking: Freight.IBooking; + pricing: Pricing; +}) { + const hasItems = priceLineItems(pricing).length > 0; + const paid = booking.paymentStatus === "PAID"; + const total = priceTotal(pricing); + + return ( + + + Payment + + {paid && } + {paid + ? "Paid" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + + {total} + + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + {hasItems && ( + <> + + + + + Total + + + {total} + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts new file mode 100644 index 000000000..80f0b7ab2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -0,0 +1,160 @@ +import { + ClipboardCheck, + FileText, + PackageCheck, + ShieldCheck, + Train, +} from "lucide-react"; + +export const PROGRESS_STAGES = [ + { + label: "Request", + icon: FileText, + statuses: ["DRAFT", "CHANGES_REQUESTED"], + }, + { + label: "Submitted", + icon: ClipboardCheck, + statuses: ["SUBMITTED", "PENDING_APPROVAL"], + }, + { + label: "Approved", + icon: ShieldCheck, + statuses: [ + "APPROVED_PENDING_SIGNATURE", + "APPROVED", + "CONTRACT_READY", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + ], + }, + { + label: "In Transit", + icon: Train, + statuses: [ + "PNR_GENERATED", + "PAYMENT_VERIFICATION_IN_PROGRESS", + "PAID", + "IN_TRANSIT", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", + ], + }, + { + label: "Complete", + icon: PackageCheck, + statuses: ["COMPLETED", "DELIVERED"], + }, +]; + +export const STATUS_MAP: Record< + string, + { title: string; description: string; stage: number } +> = { + DRAFT: { + title: "Draft — not submitted", + description: + "This booking is being prepared and hasn’t been submitted for review yet.", + stage: 0, + }, + CHANGES_REQUESTED: { + title: "Changes requested", + description: "Staff has requested changes. Please review and resubmit.", + stage: 0, + }, + SUBMITTED: { + title: "Submitted for review", + description: "Your booking has been submitted and is awaiting review.", + stage: 1, + }, + PENDING_APPROVAL: { + title: "Pending approval", + description: "Your booking is moving through the approval process.", + stage: 1, + }, + APPROVED_PENDING_SIGNATURE: { + title: "Approved — awaiting signature", + description: "Approved. Your contract will be ready to sign shortly.", + stage: 2, + }, + APPROVED: { + title: "Approved", + description: "Your booking has been fully approved.", + stage: 2, + }, + CONTRACT_READY: { + title: "Contract ready to sign", + description: + "Your contract is ready. Review and apply your signature to proceed.", + stage: 2, + }, + SIGNED_CUSTOMER: { + title: "Signed — awaiting staff", + description: + "Your signature has been submitted. Awaiting the final staff signature.", + stage: 2, + }, + FULLY_EXECUTED: { + title: "Contract fully executed", + description: "Signed by all parties. You can now proceed to payment.", + stage: 2, + }, + PNR_GENERATED: { + title: "Payment reference generated", + description: + "A payment reference number has been generated for this booking.", + stage: 3, + }, + PAYMENT_VERIFICATION_IN_PROGRESS: { + title: "Verifying payment", + description: "Your payment is being verified.", + stage: 3, + }, + PAID: { + title: "Payment confirmed", + description: "Payment has been confirmed for this booking.", + stage: 3, + }, + IN_TRANSIT: { + title: "Cargo moving", + description: "Your shipment is currently moving through the rail network.", + stage: 3, + }, + PENDING_CONSOLIDATION: { + title: "Pending consolidation", + description: "Awaiting a consolidation partner shipment.", + stage: 3, + }, + CONSOLIDATED: { + title: "Consolidated", + description: "Cargo has been consolidated with a partner shipment.", + stage: 3, + }, + COMPLETED: { + title: "Service complete", + description: "Cargo delivered and service successfully terminated.", + stage: 4, + }, + DELIVERED: { + title: "Service complete", + description: "Cargo delivered and service successfully terminated.", + stage: 4, + }, + REJECTED: { + title: "Booking rejected", + description: "This booking request has been rejected.", + stage: -1, + }, + CANCELLED: { + title: "Booking cancelled", + description: "This booking process has been terminated.", + stage: -1, + }, +}; + +export const REQUIRED_DOC_FIELDS = [ + { key: "commercial_invoice", label: "Commercial Invoice" }, + { key: "packing_list", label: "Packing List" }, + { key: "certificate_of_origin", label: "Certificate of Origin" }, + { key: "letter_of_credit", label: "Letter of Credit / LC" }, +]; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx new file mode 100644 index 000000000..02e0318fd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -0,0 +1,86 @@ +import { Box, Center, Loader, Stack, Text } from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle } from "lucide-react"; +import { useParams } from "react-router-dom"; + +import { api } from "@/services/api"; + +import { DraftBookingView } from "./DraftBookingView"; +import { PageShell, SectionCard } from "./components/layout"; +import { ReadonlyBookingView } from "./ReadonlyBookingView"; +import { isDraftLike } from "./utils"; + +export default function BookingDetailPage() { + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + + const { + data: booking, + isLoading, + isError, + error, + } = useQuery( + api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }), + ); + + const refetchBooking = () => { + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); + }; + + if (isLoading) { + return ( +
+ + + + Loading booking details… + + +
+ ); + } + + if (isError || !booking) { + return ( + + + + + + + + {isError ? "Failed to load booking" : "Booking not found"} + + {isError && ( + + {error instanceof Error + ? error.message + : "An unexpected error occurred."} + + )} + + + + ); + } + + if (isDraftLike(booking.status)) { + return ( + + ); + } + return ; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts new file mode 100644 index 000000000..031a2d38c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -0,0 +1,50 @@ +import { format } from "date-fns"; + +import type { Freight } from "@edr/types"; + +export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; +export const isDraftLike = (s: string) => + s === "DRAFT" || s === "CHANGES_REQUESTED"; + +export function fmtDate(value?: string | null) { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? "—" : format(d, "MMM d, yyyy"); +} + +export function yardLabel(y?: Freight.IBooking["originYard"]) { + return y?.label ?? y?.code ?? "—"; +} + +export function containerSummary(b: Freight.IBooking) { + if (b.containers?.length) { + return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", "); + } + return b.freightType === "BULK" ? "Bulk cargo" : "—"; +} + +export function bookingSubtitle(b: Freight.IBooking) { + const cargo = + b.freightSubtype || + (b.freightType === "BULK" ? "Bulk freight" : "Container freight"); + const load = containerSummary(b); + const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`; + return [cargo, load, route].filter((p) => p && p !== "—").join(" · "); +} + +// ─── Pricing helpers ────────────────────────────────────────────────────────── + +export type Pricing = Freight.PricingBreakdown | null | undefined; + +export function priceLineItems(pricing: Pricing) { + return (pricing?.lineItems ?? []).map((li) => ({ + label: li.description, + value: `${li.amount.toLocaleString()} ${li.currency}`, + })); +} + +export function priceTotal(pricing: Pricing) { + if (!pricing) return "—"; + const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0); + return `${total.toLocaleString()} ${pricing.currency}`; +} 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 b72d18ee1..fa1900734 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/EditBookingPage.tsx @@ -1,63 +1,99 @@ -import { useMemo } from "react"; -import { useFieldArray, Controller, useForm } from "react-hook-form"; +import { useMemo, useRef, type ReactNode } from "react"; +import { 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 { + ActionIcon, + Alert, + Box, + Button, + Center, + Divider, + Group, + Loader, + Paper, + SimpleGrid, + Stack, + Switch, + Text, + Textarea, + TextInput, + Title, +} from "@mantine/core"; import { AlertCircle, + AlertTriangle, Check, - LoaderCircle, - Loader2, - Package, - Weight, - Plus, - Trash2, - MapPin, + Download, + FileText, Flame, + MapPin, Snowflake, Truck, - FileText, + Upload, + X, } 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, + BOOKING_DOCS_SETTING, bookingFormSchema, getRouteDirection, initialBookingFormValues, + type BookingDocuments, type BookingFormValues, type RouteDirection, } from "./new-booking-form/schema"; +import { SelectField } from "./new-booking-form/shared"; +import { Step5CargoDetails } from "./new-booking-form/steps"; import { - SelectField, - SelectItem, - AlertBox, -} from "./new-booking-form/shared"; + CountChip, + DocRow, + IconSquare, +} from "./BookingDetailPage/components/Documents"; -function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { +function yardNameFromBooking( + yard: { label?: string; code?: string; name?: string } | undefined | null, +): string { return yard?.label ?? yard?.name ?? yard?.code ?? ""; } +/** Fallback container type for a size, used only when a booking row has no + * loaded containerType relation. */ +function defaultContainerTypeForSize( + referenceData: Freight.BookingReferenceData, + size: string, +): string { + const norm = (s: string) => s.toLowerCase().replace(/\s|ft/g, ""); + const group = + referenceData.containers.find((g) => norm(g.size) === norm(size)) ?? + referenceData.containers[0]; + return group?.types[0]?.name ?? ""; +} + +/** The API returns the `bookingContainers` relation (with `containerType` + * loaded), not the `{ type, qty, vgm }` shape the frontend type advertises. */ +interface BookingContainerRow { + quantity: number; + vgmPerUnitTons: number | string; + containerType?: { + sizeFt?: number | null; + label?: string | null; + code?: string | null; + } | null; +} + function mapBookingToFormValues( booking: Freight.IBooking, referenceData: Freight.BookingReferenceData, ): BookingFormInputValues { const vals: BookingFormInputValues = { ...initialBookingFormValues, - contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", + contractType: + (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new", previousContractRef: booking.previousContractId ?? "", serviceType: booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail", @@ -70,7 +106,9 @@ function mapBookingToFormValues( deliveryAddress: booking.lastMileDeliveryAddress ?? "", }, equipmentReturn: - booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return", + booking.equipmentReturn === "WITH_RETURN" + ? "with_return" + : "without_return", originYard: yardNameFromBooking(booking.originYard), destinationYard: yardNameFromBooking(booking.destinationYard), cargoType: booking.freightType === "BULK" ? "bulk" : "container", @@ -80,7 +118,8 @@ function mapBookingToFormValues( shippingLine: (booking as any).shippingLine?.name ?? "", consolidationEnabled: booking.allowConsolidation ?? false, notes: "", - termsAccepted: false, + // Terms were accepted at creation; editing shouldn't re-gate on them. + termsAccepted: true, freightType: "", bulkCommoditytype: "", containers: [], @@ -98,22 +137,114 @@ function mapBookingToFormValues( } } - 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), - })); + if (booking.freightType === "CONTAINER") { + const rows = ((booking as any).bookingContainers ?? []) as BookingContainerRow[]; + vals.containers = + rows.length > 0 + ? rows.map((bc) => { + const size = + bc.containerType?.sizeFt === 40 + ? ("40ft" as const) + : ("20ft" as const); + const typeName = bc.containerType?.label?.trim() + ? bc.containerType.label + : (bc.containerType?.code ?? + defaultContainerTypeForSize(referenceData, size)); + return { + type: size, + containerType: typeName, + qty: String(bc.quantity ?? 1), + vgm: String(Number(bc.vgmPerUnitTons ?? 0)), + }; + }) + : [ + { + type: "20ft" as const, + containerType: defaultContainerTypeForSize(referenceData, "20ft"), + qty: "1", + vgm: "", + }, + ]; } return vals; } +function SectionHeading({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + + {title} + + + {description} + + + ); +} + +function ToggleRow({ + icon, + title, + description, + checked, + onChange, + children, +}: { + icon: ReactNode; + title: string; + description: string; + checked: boolean; + onChange: (value: boolean) => void; + children?: ReactNode; +}) { + return ( + + + + {icon} + + + {title} + + + {description} + + + + onChange(e.currentTarget.checked)} + color="edr-green" + /> + + {children} + + ); +} + +const DIRECTION_COLOR: Record = { + export: "blue", + import: "yellow", + domestic: "gray", +}; +const DIRECTION_LABEL: Record = { + export: "Export workflow (inside country to outside country)", + import: "Import workflow (outside country to inside country)", + domestic: "Domestic corridor", +}; + export default function EditBookingPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const docInputRefs = useRef>({}); const bookingQuery = useQuery( api.bookings.get.queryOptions({ @@ -128,16 +259,6 @@ export default function EditBookingPage() { }), ); - 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 => { @@ -152,25 +273,42 @@ export default function EditBookingPage() { mode: "onChange", }); + const updateMutation = useMutation({ + mutationFn: async (payload: Partial) => { + const result = await api.bookings.update.call({ id: id!, dto: payload }); + + // Upload any newly attached documents against the existing booking. + const documents = (form.getValues("documents") ?? {}) as BookingDocuments; + const hasDocuments = Object.values(documents).some((value) => + Array.isArray(value) ? value.length > 0 : Boolean(value), + ); + if (hasDocuments) { + await api.bookings.uploadDocuments.call({ id: id!, files: documents }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); + navigate(`/bookings/${id}`); + }, + }); + 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 documents = (form.watch("documents") ?? {}) as BookingDocuments; 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) => ({ @@ -188,37 +326,13 @@ export default function EditBookingPage() { })); }, [referenceData]); - const freightTypeGroups = useMemo(() => { - if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.filter( - (g) => g.code !== "CONTAINER", + const setDocument = (key: string, file: File | null) => { + const current = (form.getValues("documents") ?? {}) as BookingDocuments; + form.setValue( + "documents", + { ...current, [key]: file }, + { shouldDirty: true }, ); - }, [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) => { @@ -242,14 +356,12 @@ export default function EditBookingPage() { const selectedChild = data.cargoType !== "container" && data.bulkCommoditytype ? cargoTree - .find((g) => g.code.toLowerCase() === data.freightType) - ?.children?.find((c) => c.name === data.bulkCommoditytype) + .find((g) => g.code.toLowerCase() === data.freightType) + ?.children?.find((c) => c.name === data.bulkCommoditytype) : undefined; const cargoTypeId = - data.cargoType === "container" - ? undefined - : selectedChild?.id ?? ""; + data.cargoType === "container" ? undefined : (selectedChild?.id ?? ""); const findContainerTypeId = (name: string): string => { for (const group of containerGroups) { @@ -262,9 +374,9 @@ export default function EditBookingPage() { const totalWeight = data.cargoType === "container" ? data.containers.reduce( - (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), - 0, - ) + (acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ) : Number(data.cargoWeight || 0); const apiPayload: Partial = { @@ -297,10 +409,10 @@ export default function EditBookingPage() { containers: data.cargoType === "container" ? data.containers.map((c) => ({ - containerTypeId: findContainerTypeId(c.containerType), - quantity: Number(c.qty || 1), - vgmPerUnitTons: Number(c.vgm || 0), - })) + containerTypeId: findContainerTypeId(c.containerType), + quantity: Number(c.qty || 1), + vgmPerUnitTons: Number(c.vgm || 0), + })) : [], ...(data.previousContractRef ? { previousContractId: data.previousContractRef } @@ -324,777 +436,488 @@ export default function EditBookingPage() { if (bookingQuery.isLoading) { return ( -
- -
+
+ + + + Loading booking… + + +
); } if (bookingQuery.isError || !booking) { return ( -
-
- -

Failed to load booking

- -
-
+ + ); } if (!formValues) { return ( -
- -
+
+ +
); } + const uploadedCodes = new Set( + booking.files?.map((f) => f.code).filter(Boolean) ?? [], + ); + const uploadedCount = BOOKING_DOCS_SETTING.fields.filter((f) => + uploadedCodes.has(f.fileKey), + ).length; + return ( -
-
-

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

-

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

+ + 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 && ( + } + radius="md" + mt="lg" + title="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. -

-
+ + {/* ── Section 1: Service ── */} + + + ( - - Additional Notes -