Merge pull request #142 from Tria-plc/freight/feat/ui-migration

UI migration Again
This commit is contained in:
yaschalew10
2026-06-12 15:24:46 +03:00
committed by GitHub
58 changed files with 7370 additions and 6085 deletions

View File

@@ -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);
}

View File

@@ -5,6 +5,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -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 (
<div className="flex items-center justify-center h-screen">
<Loader2 className="animate-spin text-primary" />
</div>
);
}
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 <FullScreenSpinner />;
}
/** Blocks unauthenticated users; renders children only with a valid session. */
function RequireAuth() {
const { isPending, isAuthenticated } = useAuth();
const location = useLocation();
if (isPending) return <FullScreenSpinner />;
if (!isAuthenticated)
return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
/**
* 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 <FullScreenSpinner />;
if (customerQuery.isSuccess && !customerQuery.data)
return <Navigate to="/onboarding" replace />;
return <Outlet />;
}
/** Keeps already-onboarded users out of the onboarding flow. */
function RequireNoCompany() {
const { customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (customerQuery.data) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/** Landing page for visitors; authenticated users go straight to the portal. */
function LandingRoute() {
const { isPending, isAuthenticated } = useAuth();
if (isPending) return <FullScreenSpinner />;
if (isAuthenticated) return <Navigate to="/portal" replace />;
return <EDRFreightLandingPage />;
}
const sidebarItems: SidebarItem[] = [
{ label: "Home", href: "/portal", icon: <Home /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Profile", href: "/profile", icon: <User /> },
{ label: "Settings", href: "/settings", icon: <Settings /> },
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
{
label: "My Bookings",
href: "/bookings",
icon: <CalendarCheck size={18} />,
},
{
label: "Tracking",
href: "/tracking",
icon: <MapPin size={18} />,
},
{
label: "Billing",
href: "/billing",
icon: <Receipt size={18} />,
},
{
section: "Account",
label: "Profile",
href: "/profile",
icon: <User size={18} />,
},
{
section: "Account",
label: "Settings",
href: "/settings",
icon: <Settings size={18} />,
},
];
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 (
<div className="flex items-center justify-center h-screen">
<Loader2 className="animate-spin text-primary" />
</div>
);
}
const { user } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
return (
<Routes>
<Route>
<Route index element={<EDRFreightLandingPage />} />
{/* Public routes */}
<Route index element={<LandingRoute />} />
<Route path="/logout" element={<LogoutHandler />} />
<Route
path="/booking/check-status/:orderId"
element={<CheckPaymentPage />}
/>
{/* Auth pages — inaccessible once logged in */}
<Route element={<RedirectIfAuthed />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route
path="/booking/check-status/:orderId"
element={<CheckPaymentPage />}
/>
</Route>
<Route
element={
<DashboardLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={userEmail}
onLogout={logout}
{/* Signup-flow pages; reached while a session already exists */}
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route element={<RequireAuth />}>
<Route element={<RequireNoCompany />}>
<Route path="/onboarding" element={<OnboardingPage />} />
</Route>
<Route element={<RequireCompany />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={userEmail}
>
<Outlet />
</AppLayout>
}
>
<Outlet />
</DashboardLayout>
}
>
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
</Route>
{/* <Route path="*" element={<Navigate to="/" replace />} /> */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
};

View File

@@ -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 (
<AppShell
layout="alt"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !mobileOpen },
}}
header={{ height: 72 }}
padding={0}
>
{/* ── Header (frosted glass; compact floating islands, mirrors the pen) ── */}
<AppShell.Header
withBorder={false}
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group
h="100%"
px={24}
pt={12}
pb={8}
justify="space-between"
wrap="nowrap"
>
{/* Left: sidebar toggle + page title */}
<Group gap={12} wrap="nowrap" align="center">
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Toggle sidebar"
>
<MenuIcon size={18} color={textColor} strokeWidth={1.8} />
</UnstyledButton>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontSize: 16,
fontWeight: 700,
color: textColor,
lineHeight: 1,
}}
>
{activePage ? activePage.label : title}
</Text>
</Group>
{/* Right: search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Search pill */}
<Group
gap={8}
align="center"
visibleFrom="sm"
style={{
width: 260,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 14px",
cursor: "text",
}}
>
<Search size={15} color={mutedColor} strokeWidth={1.8} />
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings
</Text>
</Group>
{/* Bell */}
<Box style={{ position: "relative" }}>
<UnstyledButton style={islandStyle} aria-label="Notifications">
<Bell size={17} color={textColor} strokeWidth={1.8} />
</UnstyledButton>
<Box
style={{
position: "absolute",
top: 7,
right: 7,
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: accentColor,
border: "1.5px solid #fff",
pointerEvents: "none",
}}
/>
</Box>
{enableThemeToggle && (
<UnstyledButton
onClick={toggleTheme}
style={islandStyle}
aria-label="Toggle theme"
>
{computedColorScheme === "dark" ? (
<Sun size={17} color={textColor} strokeWidth={1.8} />
) : (
<Moon size={17} color={textColor} strokeWidth={1.8} />
)}
</UnstyledButton>
)}
{/* Avatar pill */}
<Menu
width={220}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<UnstyledButton
style={{
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 10px 0 4px",
display: "flex",
alignItems: "center",
gap: 7,
cursor: "pointer",
}}
>
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<ChevronDown size={15} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor }}
>
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}
>
Settings
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() => navigate("/bookings/new")}
>
New Booking
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => navigate("/logout")}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
{/* ── Sidebar ── */}
<AppShell.Navbar
withBorder={false}
style={{
backgroundColor: "#ffffff",
borderRight: `1px solid ${borderColor}`,
display: "flex",
flexDirection: "column",
}}
>
{/* Brand */}
<Box
style={{
height: 60,
flexShrink: 0,
display: "flex",
alignItems: "center",
padding: "0 18px",
justifyContent: "space-between",
}}
>
<Group gap={11} wrap="nowrap">
<img
src="/assets/edr-logo.png"
alt="EDR"
style={{
width: 40,
height: 40,
objectFit: "contain",
flexShrink: 0,
}}
/>
<Box>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontWeight: 800,
fontSize: 19,
letterSpacing: "0.03em",
color: primaryColor,
lineHeight: 1.15,
}}
>
EDR FREIGHT
</Text>
<Text
style={{
fontSize: 10.5,
fontWeight: 500,
color: mutedColor,
lineHeight: 1.2,
}}
>
EthioDjibouti Railway
</Text>
</Box>
</Group>
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Close sidebar"
>
<X size={18} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Box>
{/* Nav */}
<ScrollArea flex={1} type="never" p="sm">
<Stack gap={3}>
{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 ? (
<Text
key={`section-${item.section}`}
size="xs"
tt="uppercase"
px="sm"
mt={i === 0 ? 4 : "md"}
mb={4}
style={{
fontWeight: 600,
color: textColor,
fontSize: 12,
}}
>
{item.section}
</Text>
) : null;
if (hasChildren) {
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active || childActive}
defaultOpened={childActive}
classNames={navClassNames(active || childActive)}
>
{item.children!.map((child) => {
const cActive = activePath === child.href.toLowerCase();
return (
<NavLink
key={child.href}
label={child.label}
active={cActive}
onClick={() => navigate(child.href)}
classNames={navClassNames(cActive)}
/>
);
})}
</NavLink>
</Fragment>
);
}
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active}
onClick={() => navigate(item.href)}
classNames={navClassNames(active)}
/>
</Fragment>
);
})}
</Stack>
</ScrollArea>
{/* Promo card */}
<Box style={{ padding: "0 12px 12px" }}>
<Box
style={{
borderRadius: 16,
overflow: "hidden",
position: "relative",
height: 260,
marginBottom: 10,
}}
>
{/* Train image fills the full card */}
<img
src="/assets/train-edr.jpg"
alt=""
style={{
position: "absolute",
bottom: "-16px",
left: 0,
right: "-70px",
width: "120%",
objectFit: "cover",
}}
/>
{/* Diagonal green overlay: lower-left → upper-right cut */}
<Box
style={{
position: "absolute",
inset: 0,
top: "-75%",
background:
"linear-gradient(165deg, #006149 40%, #0DC9A4 70%, #0DC9A402 80%)",
clipPath: "polygon(0 0, 100% 0, 100% 58%, 0 72%)",
}}
/>
{/* Text sits on top of the green overlay */}
<Box
style={{
position: "relative",
zIndex: 1,
padding: "16px 16px 0",
}}
className="max-w-[180px]"
>
<Text
style={{
fontSize: 15,
fontWeight: 800,
color: "#fff",
lineHeight: 1.3,
marginBottom: 4,
}}
>
Moving Africa Forward
</Text>
<Text
style={{
fontSize: 11,
color: "rgba(255,255,255,0.7)",
lineHeight: 1.5,
}}
>
Reliable. Efficient. Connected.
</Text>
</Box>
{/* Learn More — visible on the image area */}
<Box
style={{
position: "absolute",
bottom: 10,
right: 10,
zIndex: 2,
display: "inline-flex",
alignItems: "center",
gap: 5,
backgroundColor: "#fff",
borderRadius: 8,
padding: "6px 12px",
cursor: "pointer",
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
color: primaryDarkColor,
}}
>
Learn More
</Text>
<span style={{ fontSize: 12, color: primaryDarkColor }}></span>
</Box>
</Box>
{/* Profile */}
<Box
style={{
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: "10px",
display: "flex",
alignItems: "center",
gap: 10,
cursor: "pointer",
}}
>
<Avatar
radius="xl"
size={38}
color="edr-green.5"
style={{ flexShrink: 0 }}
>
<Text fw={600} fz={13} c="white">
{initials}
</Text>
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor, lineHeight: 1.3 }}
>
{userName}
</Text>
{userEmail && (
<Text
size="xs"
truncate
style={{ color: mutedColor, lineHeight: 1.3 }}
>
{userEmail}
</Text>
)}
</Box>
<ChevronDown
size={16}
color={mutedColor}
style={{ flexShrink: 0 }}
/>
</Box>
</Box>
</AppShell.Navbar>
{/* ── Main ── */}
<AppShell.Main
style={{
backgroundColor: bgColor,
backgroundImage:
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.0.8) 38%, rgba(14,163,113,0.04) 72%)",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
}}
>
{children}
</AppShell.Main>
</AppShell>
);
}
export default AppLayout;

View File

@@ -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 (
<div className="min-h-screen bg-background text-foreground">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-xl font-bold">EDR Freight</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics Platform
</p>
</div>
</div>
<div className="mt-16 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</div>
<h2 className="mt-6 text-4xl font-bold leading-tight tracking-tight">
{left.title}
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
{left.description}
</p>
</div>
<div className="mt-14 grid gap-5">
<Box className="min-h-screen bg-white text-[var(--mantine-color-gray-9)]">
<Box className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
{/* ── Left: branded panel ─────────────────────────────────────── */}
<Box className="relative hidden overflow-hidden bg-gradient-to-br from-emerald-900 via-emerald-700 to-emerald-600 px-12 py-10 text-white lg:flex lg:flex-col lg:justify-between">
{/* rail-line motif */}
<Box
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-[360px] opacity-10 bg-[repeating-linear-gradient(90deg,#fff_0,#fff_2px,transparent_2px,transparent_18px)] [mask-image:linear-gradient(90deg,transparent_0%,#000_90%)] [-webkit-mask-image:linear-gradient(90deg,transparent_0%,#000_90%)]"
/>
{/* corner glow */}
<Box
aria-hidden
className="pointer-events-none absolute -right-24 -top-24 h-[320px] w-[320px] rounded-full blur-2xl bg-[radial-gradient(circle,rgba(255,255,255,0.16),transparent_70%)]"
/>
{/* Brand */}
<Group gap="sm" className="relative z-10">
<Box className="flex size-14 items-center justify-center rounded-3xl border border-white/20 bg-white/10 backdrop-blur">
<Train className="size-7" />
</Box>
<Box>
<Text fw={700} fz={20} className="leading-tight">
EDR Freight
</Text>
<Text fz="sm" className="text-white/70">
Railway Logistics Platform
</Text>
</Box>
</Group>
{/* Headline + features */}
<Box className="relative z-10 my-10 max-w-lg">
<Box className="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</Box>
<Title
order={1}
mt="lg"
c="white"
className="text-4xl! font-bold! leading-tight tracking-tight"
>
{left.title}
</Title>
<Text mt="lg" fz="lg" className="leading-8 text-white/85">
{left.description}
</Text>
<Stack gap="md" mt={40}>
{left.features.map((item) => (
<div key={item} className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<Group key={item} gap="sm" wrap="nowrap">
<Box className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">{item}</span>
</div>
</Box>
<Text fw={500} className="text-white/95">
{item}
</Text>
</Group>
))}
</div>
</div>
</div>
<div
</Stack>
</Box>
{/* spacer keeps brand pinned top / content centered */}
<Box className="relative z-10" />
</Box>
{/* ── Right: form area ────────────────────────────────────────── */}
<Box
className={cn(
"flex items-center justify-center p-6 md:p-10",
"flex items-center justify-center bg-[var(--mantine-color-gray-0)] p-6 md:p-10",
contentClassName,
)}
>
<div className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Box className="w-full max-w-xl">
{/* Mobile brand */}
<Group gap="sm" mb="xl" className="lg:hidden!">
<ThemeIcon
size={48}
radius="lg"
variant="gradient"
gradient={{ from: "edr-green.5", to: "edr-green.7", deg: 135 }}
>
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
</ThemeIcon>
<Box>
<Text fw={700} fz={22}>
EDR Freight
</Text>
<Text fz="sm" c="dimmed">
Railway Logistics Platform
</p>
</div>
</div>
<div>{children}</div>
</div>
</div>
</div>
</div>
</Text>
</Box>
</Group>
<Box>{children}</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -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<TextInputProps>;
interface PhoneInputProps {
disabled?: boolean;
countryCode?: React.ComponentProps<typeof Input>;
phone?: React.ComponentProps<typeof Input>;
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 (
<Field data-invalid={Boolean(countryCodeError || phoneError)}>
<FieldLabel>{label}</FieldLabel>
<div className="flex gap-2">
<Input
type="text"
<Stack gap={6}>
<Text size="sm" fw={500} c="edr-text">{label}</Text>
<Group gap={8} wrap="nowrap" align="flex-start">
<TextInput
w={80}
disabled={disabled}
className="w-20"
aria-invalid={Boolean(countryCodeError)}
error={Boolean(countryCodeError)}
styles={{ input: { textAlign: "center" } }}
{...countryCodeProps}
/>
<Input
type="tel"
<TextInput
style={{ flex: 1 }}
placeholder="912345678"
disabled={disabled}
className="flex-1"
aria-invalid={Boolean(phoneError)}
error={Boolean(phoneError)}
{...phoneProps}
/>
</div>
<FieldError errors={[countryCodeError, phoneError]} />
</Field>
</Group>
{errorMsg && (
<Text size="xs" c="red.6">{errorMsg}</Text>
)}
</Stack>
);
}

View File

@@ -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,

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</StrictMode>,
);

View File

@@ -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<string, StageConfig> = {
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<string, { bg: string; c: string; bd?: string }> = {
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<InvoiceStatus, { label: string; bg: string; text: string }> = {
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<Tab>("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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Documents banner */}
{!me.documentsComplete && !dismissed && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
<UploadCloud className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="flex-1">
<p className="font-semibold">Upload your documents</p>
<p className="mt-0.5 text-amber-700">
To enable all account features, please upload your Business
License, TIN Certificate, and National ID / Passport.
</p>
<Link
to="/settings?tab=documents"
className="mt-2 inline-flex items-center gap-1 font-medium text-amber-900 underline underline-offset-2 transition hover:text-amber-700"
>
Upload now
</Link>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 rounded-lg p-1 text-amber-400 transition hover:bg-amber-100 hover:text-amber-600"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
{/* ── Hello Row ─────────────────────────────────────────────────────── */}
<Group
justify="space-between"
align="center"
gap="md"
className="!flex-col !items-stretch md:!flex-row md:!items-center"
>
<Box>
<Text size="sm" c="edr-muted">{greeting}</Text>
<Text fz={26} fw={800} mt={2} c="edr-text" className="tracking-tight">
{companyName} 👋
</Text>
</Box>
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">{me.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
{/* Book a shipment CTA */}
<Group
component={Link as any}
to="/bookings/new"
gap={14}
align="center"
wrap="nowrap"
bg="edr-green"
px={18}
py={14}
className="w-full md:!w-[240px] rounded-2xl no-underline shadow-[0_6px_10px_-12px_rgba(14,163,83,0.8)]"
>
<Truck size={22} color="#fff" />
<Box className="min-w-0 flex-1">
<Text fz={14} fw={700} c="white" lh={1.3}>Book a shipment</Text>
</Box>
<Box className="flex h-[32px] w-[32px] shrink-0 items-center justify-center rounded-full bg-white">
<ArrowRight size={18} color={cv("edr-green.7")} />
</Box>
</Group>
</Group>
<div className="flex flex-wrap items-center gap-2">
<Link to="/bookings/new">
<Button
variant="secondary"
className="bg-white text-[#10B981] hover:bg-muted"
>
<Plus className="h-4 w-4" />
New Booking
</Button>
</Link>
<Link to="/tracking">
<Button
variant="outline"
className="border-white/40 text-white hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Button>
</Link>
</div>
</div>
</div>
{/* ── Stats Strip ───────────────────────────────────────────────────── */}
<Box className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
<StatKpi icon={Truck} label="Active Shipments" value={activeBookings.length.toString()} delta="+2 this week" deltaColor="edr-green.7" />
<StatKpi icon={Clock3} label="Awaiting Payment" value={outstandingInvoices.length.toString()} delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`} deltaColor="edr-amber-text" divider />
<StatKpi icon={CheckCircle2} label="Delivered (May)" value={deliveredCount.toString()} delta="96% on-time" deltaColor="edr-muted" divider />
<StatKpi icon={Wallet} label="Spend YTD" value="ETB 1.24M" delta="+16% YoY" deltaColor="edr-green.7" divider />
</SimpleGrid>
</Box>
{/* Active Shipments */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Active Shipments</CardTitle>
<CardDescription>
Live tracking for your in-flight cargo
</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
{/* ── Mid Row: Shipments + Invoices ─────────────────────────────────── */}
<Grid align="stretch">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card className="h-full" padding={28}>
<Group justify="space-between" align="center" mb={18} wrap="nowrap">
<Box>
<Text fz={19} fw={800} c="edr-text">My Shipments</Text>
<Text fz={13} c="edr-muted">From draft to delivery every booking in one place</Text>
</Box>
</Group>
<CardContent>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
No shipments currently in transit.
</p>
{bookingsQuery.isPending ? (
<Stack gap={6}>{[1, 2, 3, 4].map((i) => <Skeleton key={i} height={64} radius="md" />)}</Stack>
) : visibleBookings.length === 0 ? (
<EmptyState message="No bookings in this view." />
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-border p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-foreground">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-muted-foreground">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-primary" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
<Stack gap={0}>
{visibleBookings.map((booking, i) => (
<BookingRow key={booking.id} booking={booking} last={i === visibleBookings.length - 1} onClick={() => navigate(`/bookings/${booking.id}`)} />
))}
</div>
</Stack>
)}
</CardContent>
</Card>
{/* Recent bookings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Bookings</CardTitle>
<CardDescription>Your latest freight requests</CardDescription>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent className="px-0">
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="px-6 py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Date</th>
<th className="py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-border transition hover:bg-primary/5 cursor-pointer"
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<td className="px-6 py-3 font-medium text-foreground">
{booking.reference}
</td>
<td className="py-3 text-muted-foreground">
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
</td>
<td className="py-3 text-muted-foreground">
{booking.freightType === "CONTAINER" ? "Container" : booking.freightType}
</td>
<td className="py-3 text-muted-foreground">
{format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</Card>
</Grid.Col>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Invoices</CardTitle>
<CardDescription>
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
total
</CardDescription>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Invoices</Text>
<Group component={Link as any} to="/billing" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Group>
<CardContent>
{/* Outstanding card */}
<Box mb={16} p={16} bg="edr-amber-soft" className="rounded-[14px]">
<Text fz={12} fw={600} c="edr-amber-text">Outstanding balance</Text>
<Text fz={24} fw={800} mt={4} c="edr-text">{formatCurrency(totalOutstanding || 377500, "ETB")}</Text>
<Group justify="space-between" align="center" mt={8} wrap="nowrap">
<Text fz={12} c="edr-amber-text">{outstandingInvoices.length || 2} invoices unpaid</Text>
<Group gap={5} align="center" px={14} py={8} bg="edr-accent" className="cursor-pointer rounded-[9px]">
<Zap size={15} color="#fff" />
<Text fz={13} fw={700} c="white">Pay all</Text>
</Group>
</Group>
</Box>
{/* Invoice list */}
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
No invoices yet.
</p>
<EmptyState message="No invoices yet." />
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-border p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-primary" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-0.5 pt-2 text-lg font-bold text-foreground">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
<Stack gap={0}>
{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 (
<Box key={invoice.id}>
{i > 0 && <Box h={1} bg="edr-divider" />}
<Stack gap={8} py={10}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fz={13} fw={700} c="edr-text">{invoice.number}</Text>
<Text fz={11} c="edr-muted">{invoice.bookingReference}</Text>
</Box>
<Text fz={14} fw={700} c="edr-text">{formatCurrency(invoice.amount, invoice.currency)}</Text>
</Group>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={5} align="center">
<DueIcon size={13} color={dueIconColor} />
<Text fz={12} c="edr-muted">{dueText}</Text>
</Group>
<Box bg={badge.bg} px={10} py={4} className="rounded-full">
<Text fz={11} fw={700} c={badge.text}>{badge.label}</Text>
</Box>
</Group>
</Stack>
</Box>
);
})}
</Stack>
)}
</CardContent>
</Card>
</div>
</div>
</Card>
</Grid.Col>
</Grid>
{/* ── Bottom Row: Freight Volume + Recent Activity ──────────────────── */}
<Grid gutter={20} align="stretch">
<Grid.Col span={{ base: 12, md: 5 }}>
<Card className="h-full" padding={24}>
<Text fz={17} fw={700} c="edr-text">Freight Volume</Text>
<Group gap={10} align="baseline" mt={4} mb={22}>
<Text fz={26} fw={800} c="edr-text">4,180 t</Text>
<Text fz={13} c="edr-muted">ETB 1.24M</Text>
<Text fz={12} fw={700} c="edr-green.7">+16% YTD</Text>
</Group>
<Group align="flex-end" gap={10} className="h-[110px]">
{VOLUME_DATA.map((val, i) => {
const isLast = i === VOLUME_DATA.length - 1;
return (
<Box key={i} className="flex flex-1 flex-col items-center gap-2">
<Box
bg={isLast ? "edr-green" : "edr-soft"}
bd={isLast ? undefined : "1px solid edr-border"}
h={Math.round((val / maxVolume) * 86)}
className="w-full rounded-t-md"
/>
<Text fz={11} c="edr-muted">{MONTHS[i]}</Text>
</Box>
);
})}
</Group>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
<Card className="h-full" padding={24}>
<Group justify="space-between" align="center" mb={16}>
<Text fz={17} fw={700} c="edr-text">Recent Activity</Text>
<Group component={Link as any} to="/bookings" gap={3} align="center" className="no-underline">
<Text fz={13} fw={600} c="edr-green.7">View all</Text>
<ChevronRight size={15} color={cv("edr-green.7")} />
</Group>
</Group>
{bookingsQuery.isPending ? (
<Stack gap={10}>{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} height={44} radius="md" />)}</Stack>
) : allBookings.length === 0 ? (
<EmptyState message="No recent activity." />
) : (
<Stack gap={2}>
{allBookings.slice(0, 6).map((booking) => (
<ActivityRow key={booking.id} booking={booking} onClick={() => navigate(`/bookings/${booking.id}`)} />
))}
</Stack>
)}
</Card>
</Grid.Col>
</Grid>
</Stack>
);
}
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 (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-0.5 text-sm text-foreground">{value}</p>
</div>
</div>
<Box p={padding} className={`rounded-[20px] border border-edr-border bg-edr-card ${className}`}>
{children}
</Box>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"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 (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
<Box px={4} className={divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined}>
<Group gap={6} align="center" mb={7} wrap="nowrap">
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
<Text fz={12} fw={600} c="edr-muted" truncate>{label}</Text>
</Group>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>{value}</Text>
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>{delta}</Text>
</Group>
</Box>
);
}
function BookingBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
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 (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status] || "bg-muted text-muted-foreground"}`}
>
{status.replace(/_/g, " ")}
</span>
<Group gap={0} align="center" wrap="nowrap" className="h-3.5 w-full">
{[0, 1, 2, 3, 4].map((i) => {
const done = i < stage;
const active = i === stage;
const size = active ? 12 : done ? 9 : 8;
return (
<Group key={i} gap={0} align="center" wrap="nowrap" className={i < 4 ? "flex-1" : undefined}>
<Box w={size} h={size} bg={done || active ? color : "edr-step-idle"} className="shrink-0 rounded-full" />
{i < 4 && <Box h={3} bg={i < stage ? color : "edr-conn-idle"} className="flex-1 rounded-full" />}
</Group>
);
})}
</Group>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
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 (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
<Box className={last ? undefined : "border-b border-edr-divider"}>
<Group gap={16} align="center" wrap="nowrap" py={14} px={4} className="cursor-pointer" onClick={onClick}>
<Box w={46} h={46} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-xl">
<Icon size={22} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1 lg:!flex-none lg:!w-[188px]">
<Text fz={15} fw={700} c="edr-text" truncate>{booking.reference}</Text>
<Text fz={12} c="edr-muted" truncate>{commodity} · {origin} {dest}</Text>
</Box>
<Box className="hidden min-w-0 flex-1 pr-2 lg:block">
<Text fz={12} fw={500} mb={8} c={cfg.iconColor} truncate>{cfg.hint}</Text>
<Stepper stage={cfg.stage} color={cfg.step} />
</Box>
<Stack gap={9} align="flex-end" className="shrink-0">
<Group gap={6} align="center" px={11} py={5} bg={cfg.badgeBg} className="rounded-full">
<Box w={6} h={6} bg={cfg.badgeDot} className="rounded-full" />
<Text fz={11} fw={700} c={cfg.badgeText}>{cfg.badgeLabel}</Text>
</Group>
<Group gap={5} align="center" px={15} py={8} bg={ap.bg} bd={ap.bd} className="cursor-pointer rounded-[9px]">
<Text fz={13} fw={700} c={ap.c}>{cfg.action.label}</Text>
{AIcon && <AIcon size={15} color={ap.c === "white" ? "#fff" : cv("edr-text")} />}
</Group>
</Stack>
</Group>
</Box>
);
}
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 (
<Group gap={12} align="center" wrap="nowrap" py={9} className="cursor-pointer" onClick={onClick}>
<Box w={36} h={36} bg={cfg.tile} className="flex shrink-0 items-center justify-center rounded-[10px]">
<Icon size={17} color={cv(cfg.iconColor)} />
</Box>
<Box className="min-w-0 flex-1">
<Text fz={13} fw={600} c="edr-text" truncate>Booking {booking.reference} {verb}</Text>
<Text fz={11} c="edr-muted" truncate>
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">{format(new Date(booking.createdAt), "MMM d")}</Text>
</Group>
);
}
function EmptyState({ message }: { message: string }) {
return (
<Box py="xl" className="rounded-xl border border-dashed border-edr-border text-center">
<Text size="sm" c="edr-muted">{message}</Text>
</Box>
);
}

View File

@@ -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<typeof onboardingSchema>;
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
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<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<CompanyStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
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<FormData>({
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
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: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
{ key: "poa", icon: <FileText size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<CompanyStep, string> = {
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 (
<>
<div className="mb-8">
<button
type="button"
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
</Button>
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={
step === "poa" || step === "documents" || step === "confirm"
}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{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`}
</p>
</div>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.vatNumber)}>
<FieldLabel>VAT Number</FieldLabel>
<Input
placeholder="VAT-12345"
aria-invalid={Boolean(errors.vatNumber)}
maxLength={10}
{...register("vatNumber")}
/>
<FieldError errors={[errors.vatNumber]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
<TextInput
label="VAT Number"
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</SimpleGrid>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</>
)}
{step === "personnel" && (
<>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</SimpleGrid>
<PhoneInput
countryCode={{
...register("contactPersonPhoneCountryCode"),
}}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</div>
</div>
<Divider color="edr-border" />
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(errors.generalManagerName)}
>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</div>
</div>
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</SimpleGrid>
</>
)}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")}
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
</Text>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</p>
</Text>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow
label="Company name"
value={formValues.companyName}
/>
<ReviewRow
label="Company email"
value={formValues.companyEmail}
/>
<ReviewRow
label="Company phone"
value={formValues.companyPhone}
/>
<ReviewRow
label="Location"
value={formValues.companyLocation}
/>
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the company details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
<ReviewRow label="Location" value={formValues.companyLocation} />
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
<ReviewRow label="General manager" value={formValues.generalManagerName} />
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
</SimpleGrid>
</Box>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<div className="flex items-center gap-3">
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
<Button
type="button"
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
</Button>
</div>
</div>
</Group>
</Stack>
</form>
</>
);
@@ -655,36 +426,13 @@ export default function CompanyProfileForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Box p={12} className="rounded-xl bg-edr-bg">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
</Text>
</Box>
);
}

View File

@@ -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<typeof djiboutiSchema>;
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
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<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<DjiboutiStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
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<FormData>({
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
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: <Building2 size={18} /> },
{ key: "representative", icon: <UserRound size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<DjiboutiStep, string> = {
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 (
<>
<div className="mb-8">
<button
type="button"
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
</Button>
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<UserRound className="size-5" />}
active={step === "representative"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{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`}
</p>
</div>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Djibouti Logistics SARL"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
<TextInput
label="Company Name"
placeholder="Djibouti Logistics SARL"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="info@djib-logistics.dj"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="info@djib-logistics.dj"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "12345678",
}}
phone={{ ...register("companyPhone"), placeholder: "12345678" }}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location / Country</FieldLabel>
<Input
placeholder="Djibouti City, Djibouti"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Boulevard de la République"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location / Country"
placeholder="Djibouti City, Djibouti"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="Address"
placeholder="Boulevard de la République"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
</>
)}
{step === "representative" && (
<>
<p className="text-sm text-muted-foreground">
<Text size="sm" c="edr-muted">
Provide the company representative details for this account.
</p>
<Field data-invalid={Boolean(errors.repName)}>
<FieldLabel>Representative Name</FieldLabel>
<Input
placeholder="Ahmed Hassan"
aria-invalid={Boolean(errors.repName)}
{...register("repName")}
</Text>
<TextInput
label="Representative Name"
placeholder="Ahmed Hassan"
error={errors.repName?.message}
{...register("repName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Representative Email"
type="email"
placeholder="ahmed@company.dj"
error={errors.repEmail?.message}
{...register("repEmail")}
/>
<FieldError errors={[errors.repName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.repEmail)}>
<FieldLabel>Representative Email</FieldLabel>
<Input
type="email"
placeholder="ahmed@company.dj"
aria-invalid={Boolean(errors.repEmail)}
{...register("repEmail")}
/>
<FieldError errors={[errors.repEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("repPhoneCountryCode") }}
phone={{
...register("repPhone"),
placeholder: "12345678",
}}
phone={{ ...register("repPhone"), placeholder: "12345678" }}
countryCodeError={errors.repPhoneCountryCode}
phoneError={errors.repPhone}
label="Representative Phone"
/>
</div>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</p>
</Text>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the company details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
@@ -366,60 +280,33 @@ export default function DjiboutiAgentForm({
<ReviewRow label="Address" value={formValues.companyAddress} />
<ReviewRow label="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow
label="Rep. phone"
value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`}
/>
</div>
</div>
<ReviewRow label="Rep. phone" value={`${formValues.repPhoneCountryCode}${formValues.repPhone}`} />
</SimpleGrid>
</Box>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
</div>
</div>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
@@ -427,37 +314,13 @@ export default function DjiboutiAgentForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Box p={12} className="rounded-xl bg-edr-bg">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
</Text>
</Box>
);
}

View File

@@ -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<typeof forwarderSchema>;
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
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<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
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<FormData>({
const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
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: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
{ key: "poa", icon: <FileText size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<ForwarderStep, string> = {
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 (
<>
<div className="mb-8">
<button
type="button"
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
</Button>
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={step !== "company"}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa" || step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={step === "documents" || step === "confirm"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{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`}
</p>
</div>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<Field data-invalid={Boolean(errors.companyName)}>
<FieldLabel>Company Name</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
aria-invalid={Boolean(errors.companyName)}
{...register("companyName")}
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyEmail)}>
<FieldLabel>Company Email</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
aria-invalid={Boolean(errors.companyEmail)}
{...register("companyEmail")}
/>
<FieldError errors={[errors.companyEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("companyPhoneCountryCode") }}
phone={{
...register("companyPhone"),
placeholder: "912345678",
}}
phone={{ ...register("companyPhone"), placeholder: "912345678" }}
countryCodeError={errors.companyPhoneCountryCode}
phoneError={errors.companyPhone}
label="Company Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.companyLocation)}>
<FieldLabel>Location</FieldLabel>
<Input
placeholder="Addis Ababa, Ethiopia"
aria-invalid={Boolean(errors.companyLocation)}
{...register("companyLocation")}
/>
<FieldError errors={[errors.companyLocation]} />
</Field>
<Field data-invalid={Boolean(errors.companyAddress)}>
<FieldLabel>Address</FieldLabel>
<Input
placeholder="Bole Subcity, Woreda 03"
aria-invalid={Boolean(errors.companyAddress)}
{...register("companyAddress")}
/>
<FieldError errors={[errors.companyAddress]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.vatNumber)}>
<FieldLabel>VAT Number</FieldLabel>
<Input
placeholder="VAT-12345"
aria-invalid={Boolean(errors.vatNumber)}
maxLength={10}
{...register("vatNumber")}
/>
<FieldError errors={[errors.vatNumber]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
{...register("fanNumber")}
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
<TextInput
label="VAT Number"
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</SimpleGrid>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</>
)}
{step === "personnel" && (
<>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.contactPersonName)}>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Jane Smith"
aria-invalid={Boolean(errors.contactPersonName)}
{...register("contactPersonName")}
/>
<FieldError errors={[errors.contactPersonName]} />
</Field>
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<PhoneInput
countryCode={{ ...register("contactPersonPhoneCountryCode") }}
phone={{ ...register("contactPersonPhone"), placeholder: "912345678" }}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</SimpleGrid>
<PhoneInput
countryCode={{
...register("contactPersonPhoneCountryCode"),
}}
phone={{
...register("contactPersonPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.contactPersonPhoneCountryCode}
phoneError={errors.contactPersonPhone}
label="Phone"
/>
</div>
</div>
<Divider color="edr-border" />
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(errors.generalManagerName)}
>
<FieldLabel>Name</FieldLabel>
<Input
placeholder="Abebe Bikila"
aria-invalid={Boolean(errors.generalManagerName)}
{...register("generalManagerName")}
/>
<FieldError errors={[errors.generalManagerName]} />
</Field>
<Field data-invalid={Boolean(errors.generalManagerEmail)}>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
aria-invalid={Boolean(errors.generalManagerEmail)}
{...register("generalManagerEmail")}
/>
<FieldError errors={[errors.generalManagerEmail]} />
</Field>
<PhoneInput
countryCode={{
...register("generalManagerPhoneCountryCode"),
}}
phone={{
...register("generalManagerPhone"),
placeholder: "912345678",
}}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</div>
</div>
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<PhoneInput
countryCode={{ ...register("generalManagerPhoneCountryCode") }}
phone={{ ...register("generalManagerPhone"), placeholder: "912345678" }}
countryCodeError={errors.generalManagerPhoneCountryCode}
phoneError={errors.generalManagerPhone}
label="Phone"
/>
</SimpleGrid>
</>
)}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are optional. Fill them in if you have
them, or skip to continue.
</p>
<Field data-invalid={Boolean(errors.poaName)}>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative Name"
aria-invalid={Boolean(errors.poaName)}
{...register("poaName")}
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
</Text>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaName]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaEmail)}>
<FieldLabel>PoA Email</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
aria-invalid={Boolean(errors.poaEmail)}
{...register("poaEmail")}
/>
<FieldError errors={[errors.poaEmail]} />
</Field>
<PhoneInput
countryCode={{ ...register("poaPhoneCountryCode") }}
phone={{ ...register("poaPhone"), placeholder: "912345678" }}
label="PoA Phone"
countryCodeError={errors.poaPhoneCountryCode}
phoneError={errors.poaPhone}
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.poaLocation)}>
<FieldLabel>PoA Location</FieldLabel>
<Input
placeholder="City, Country"
aria-invalid={Boolean(errors.poaLocation)}
{...register("poaLocation")}
/>
<FieldError errors={[errors.poaLocation]} />
</Field>
<Field data-invalid={Boolean(errors.poaAddress)}>
<FieldLabel>PoA Address</FieldLabel>
<Input
placeholder="Full Address"
aria-invalid={Boolean(errors.poaAddress)}
{...register("poaAddress")}
/>
<FieldError errors={[errors.poaAddress]} />
</Field>
</div>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</p>
</Text>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the company details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the company details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="Company name" value={formValues.companyName} />
<ReviewRow label="Company email" value={formValues.companyEmail} />
<ReviewRow label="Company phone" value={formValues.companyPhone} />
@@ -559,96 +392,41 @@ export default function ForwarderForm({
<ReviewRow label="TIN" value={formValues.tinNumber} />
<ReviewRow label="VAT" value={formValues.vatNumber} />
<ReviewRow label="FAN" value={formValues.fanNumber} />
<ReviewRow
label="Contact person"
value={formValues.contactPersonName}
/>
<ReviewRow
label="Contact phone"
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
/>
<ReviewRow
label="General manager"
value={formValues.generalManagerName}
/>
<ReviewRow
label="GM email"
value={formValues.generalManagerEmail}
/>
<ReviewRow
label="GM phone"
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
/>
<ReviewRow
label="PoA name"
value={formValues.poaName || undefined}
/>
<ReviewRow
label="PoA phone"
value={
formValues.poaPhone && formValues.poaPhoneCountryCode
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
: undefined
}
/>
<ReviewRow
label="PoA email"
value={formValues.poaEmail || undefined}
/>
<ReviewRow
label="PoA location"
value={formValues.poaLocation || undefined}
/>
</div>
</div>
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
<ReviewRow label="General manager" value={formValues.generalManagerName} />
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
</SimpleGrid>
</Box>
)}
</FieldGroup>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "company"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
</div>
</div>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
@@ -656,36 +434,13 @@ export default function ForwarderForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Box p={12} className="rounded-xl bg-edr-bg">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
</Text>
</Box>
);
}

View File

@@ -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<LoginMethod>("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%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Mail className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Welcome back</h2>
<p className="mt-2 text-base text-muted-foreground">
Enter your credentials to access your portal
</p>
</div>
<Stack gap="xs" mb="lg">
<Box
w={48}
h={48}
bg="edr-soft"
className="flex items-center justify-center rounded-2xl"
>
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Welcome back
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Enter your credentials to access your portal
</Text>
</Box>
</Stack>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex gap-2 rounded-lg bg-muted p-1">
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("email")}
className={`flex-1 hover:bg-background/40! ${method === "email" ? "bg-background shadow border" : ""}`}
>
<Mail data-icon="inline-start" />
Email
</Button>
<Button
type="button"
variant={"ghost"}
size="sm"
onClick={() => setMethod("phone")}
className={`flex-1 hover:bg-background/40! ${method === "phone" ? "bg-background shadow border" : ""}`}
>
<Phone data-icon="inline-start" />
Phone
</Button>
</div>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<SegmentedControl
value={method}
onChange={(v) => setMethod(v as LoginMethod)}
fullWidth
radius="md"
data={[
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Mail size={15} />
<Text size="sm">Email</Text>
</Group>
),
value: "email",
},
{
label: (
<Group gap={6} justify="center" wrap="nowrap">
<Phone size={15} />
<Text size="sm">Phone</Text>
</Group>
),
value: "phone",
},
]}
/>
<FieldGroup>
{method === "email" ? (
<Field>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="name@company.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
</Field>
<TextInput
label="Email Address"
placeholder="name@company.com"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
disabled={loading}
/>
) : (
<PhoneInput
disabled={loading}
countryCode={{
value: countryCode,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setCountryCode(e.target.value),
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setCountryCode(e.target.value),
}}
phone={{
value: phoneNumber,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value),
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
setPhoneNumber(e.target.value),
}}
/>
)}
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Password</FieldLabel>
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={500} c="edr-text">
Password
</Text>
<Button
type="button"
variant="link"
variant="transparent"
size="xs"
className="h-auto p-0"
c="edr-green.6"
p={0}
h="auto"
fz={12}
>
Forgot password?
</Button>
</div>
<Input
type="password"
</Group>
<PasswordInput
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</Field>
</FieldGroup>
</Box>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}
</div>
)}
<Button type="submit" disabled={loading} size="lg" className="w-full">
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight data-icon="inline-end" />
</>
{error && (
<Alert color="red" variant="light" radius="md">
{error}
</Alert>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigate("/signup")}
className="h-auto p-0 font-semibold"
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Create an account
Sign In
</Button>
</p>
<Text size="sm" c="edr-muted" ta="center">
Don't have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
onClick={() => navigate("/signup")}
>
Create an account
</Button>
</Text>
</Stack>
</form>
</AuthLayout>
);

View File

@@ -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: <ArrowDownToLine className="size-6" />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine className="size-6" />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 className="size-6" />,
},
{
id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)",
description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship className="size-6" />,
},
{
id: "transporter",
label: "Transporter",
description: "Trucking company providing first/last-mile services.",
icon: <Truck className="size-6" />,
},
];
{
id: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
{
id: "freight-forwarder-dj",
label: "FF Agent (Djibouti)",
description: "Djibouti-based agent coordinating cross-border logistics.",
icon: <Ship size={22} />,
},
{
id: "transporter",
label: "Transporter",
description: "Trucking company providing first/last-mile services.",
icon: <Truck size={22} />,
},
];
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<OnboardingUserType, string> = {
@@ -127,9 +120,7 @@ export default function OnboardingPage() {
const queryClient = useQueryClient();
const { user } = useAuth();
const [userType, setUserType] = useState<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const [documentFiles, setDocumentFiles] = useState<Record<string, File | File[] | null>>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
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 (
<AuthLayout left={PREFLIGHT_LEFT}>
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold tracking-tight">
<Stack gap="lg">
<Box>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Select Account Type
</h2>
<p className="mt-1 text-sm text-muted-foreground">
</Text>
<Text size="sm" c="edr-muted" mt={4}>
Choose the account type that fits your role.
</p>
</div>
</Text>
</Box>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{USER_TYPE_CARDS.map((card) => (
<button
<UnstyledButton
key={card.id}
type="button"
onClick={() => handleSelectType(card.id)}
className="group relative flex flex-col items-start gap-3 rounded-xl border-2 border-border bg-card p-4 text-left transition-all hover:border-primary hover:bg-primary/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group block rounded-lg shadow-lg! border! border-edr-border! bg-edr-card! p-5! text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft hover:shadow-[0_12px_28px_-14px_rgba(14,163,83,0.55)]"
>
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
{card.icon}
</div>
<div>
<p className="font-semibold text-foreground">{card.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
{card.description}
</p>
</div>
<span className="absolute right-3 top-3 flex size-5 items-center justify-center rounded-full border-2 border-border text-transparent transition-all group-hover:border-primary group-hover:text-primary">
<Check className="size-3" />
</span>
</button>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant="light"
color="edr-green"
className="shrink-0 transition-colors group-hover:!bg-[var(--mantine-color-edr-green-6)] group-hover:!text-white"
>
{card.icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{card.label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{card.description}
</Text>
</Box>
<ChevronRight
size={18}
className="shrink-0 text-[var(--mantine-color-edr-muted-6)] transition-all group-hover:translate-x-0.5 group-hover:text-[var(--mantine-color-edr-green-6)]"
/>
</Group>
</UnstyledButton>
))}
</div>
</div>
</SimpleGrid>
</Stack>
</AuthLayout>
);
}
// 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 (

View File

@@ -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<typeof passwordSchema>;
export default function SetPasswordPage() {
const navigate = useNavigate();
const { setPassword } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(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%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<LockKeyhole className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Set Password</h2>
<p className="mt-2 text-base text-muted-foreground">
Create a secure password for your account.
</p>
</div>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<LockKeyhole size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Set Password
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Create a secure password for your account.
</Text>
</Box>
</Stack>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</div>
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel>Password</FieldLabel>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Enter password"
disabled={loading}
aria-invalid={Boolean(errors.password)}
className="pr-12"
{...register("password")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.password]} />
</Field>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<PasswordInput
label="Password"
placeholder="Enter password"
disabled={loading}
error={errors.password?.message}
{...register("password")}
/>
{password && (
<Stack gap={4} mt={8}>
{requirements.map((req) => (
<Group key={req.label} gap={6} align="center" wrap="nowrap">
<ThemeIcon
size={16}
radius="xl"
variant={req.met ? "filled" : "light"}
color={req.met ? "edr-green" : "gray"}
>
{req.met ? <Check size={10} /> : <X size={10} />}
</ThemeIcon>
<Text size="xs" c={req.met ? "edr-green.7" : "edr-muted"}>
{req.label}
</Text>
</Group>
))}
</Stack>
)}
</Box>
{password && (
<ul className="space-y-1.5">
{requirements.map((req) => (
<li
key={req.label}
className={cn(
"flex items-center gap-2 text-sm",
req.met ? "text-emerald-600" : "text-muted-foreground",
)}
>
{req.met ? (
<Check className="size-4 shrink-0 text-emerald-500" />
) : (
<X className="size-4 shrink-0 text-muted-foreground/50" />
)}
{req.label}
</li>
))}
</ul>
)}
<PasswordInput
label="Confirm Password"
placeholder="Confirm password"
disabled={loading}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<Field data-invalid={Boolean(errors.confirmPassword)}>
<FieldLabel>Confirm Password</FieldLabel>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm password"
disabled={loading}
aria-invalid={Boolean(errors.confirmPassword)}
className="pr-12"
{...register("confirmPassword")}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? <EyeOff /> : <Eye />}
</Button>
</div>
<FieldError errors={[errors.confirmPassword]} />
</Field>
</FieldGroup>
<Button
type="submit"
disabled={loading || !allMet}
size="lg"
className="w-full"
>
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Saving...
</>
) : (
<>
Save Password
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<Button
type="submit"
disabled={loading || !allMet}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Save Password
</Button>
</Stack>
</form>
</AuthLayout>
);

View File

@@ -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<string | null>(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 (
<AuthLayout
left={{
@@ -144,68 +114,56 @@ export default function SignupPage() {
"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%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<UserPlus className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">Create Account</h2>
<p className="mt-2 text-base text-muted-foreground">
Register to access EDR Freight services.
</p>
</div>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
Create Account
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Register to access EDR Freight services.
</Text>
</Box>
</Stack>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</div>
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
<div className="flex gap-4">
<Field data-invalid={Boolean(errors.firstName?.en)}>
<FieldLabel>First Name</FieldLabel>
<Input
type="text"
placeholder="John"
disabled={loading}
aria-invalid={Boolean(errors.firstName?.en)}
{...register("firstName.en")}
/>
<FieldError errors={[errors.firstName?.en]} />
</Field>
<Field data-invalid={Boolean(errors.lastName?.en)}>
<FieldLabel>Last Name</FieldLabel>
<Input
type="text"
placeholder="Doe"
disabled={loading}
aria-invalid={Boolean(errors.lastName?.en)}
{...register("lastName.en")}
/>
<FieldError errors={[errors.lastName?.en]} />
</Field>
</div>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={2} spacing="md">
<TextInput
label="First Name"
placeholder="John"
disabled={loading}
aria-invalid={Boolean(errors.email)}
{...register("email")}
error={errors.firstName?.en?.message}
{...register("firstName.en")}
/>
<FieldError errors={[errors.email]} />
</Field>
<TextInput
label="Last Name"
placeholder="Doe"
disabled={loading}
error={errors.lastName?.en?.message}
{...register("lastName.en")}
/>
</SimpleGrid>
<TextInput
label="Email Address"
placeholder="john@example.com"
type="email"
disabled={loading}
error={errors.email?.message}
{...register("email")}
/>
<PhoneInput
disabled={loading}
@@ -215,108 +173,73 @@ export default function SignupPage() {
phoneError={errors.phone}
/>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel>Password</FieldLabel>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder="Create a strong password"
disabled={loading}
aria-invalid={Boolean(errors.password)}
className="pr-10"
{...register("password")}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<FieldError errors={[errors.password]} />
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(watch("password") ?? "");
return (
<div
key={req.label}
className={cn(
"flex items-center gap-1.5 text-xs transition-colors",
met ? "text-emerald-600" : "text-muted-foreground",
)}
>
{met ? (
<Check className="size-3" />
) : (
<X className="size-3" />
)}
{req.label}
</div>
);
})}
</div>
</Field>
<Box>
<PasswordInput
label="Password"
placeholder="Create a strong password"
disabled={loading}
error={errors.password?.message}
{...register("password")}
/>
{passwordValue.length > 0 && (
<Stack gap={4} mt={8}>
{passwordRequirements.map((req) => {
const met = req.test(passwordValue);
return (
<Group key={req.label} gap={6} align="center" wrap="nowrap">
<ThemeIcon
size={16}
radius="xl"
variant={met ? "filled" : "light"}
color={met ? "edr-green" : "gray"}
>
{met ? <Check size={10} /> : <X size={10} />}
</ThemeIcon>
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
{req.label}
</Text>
</Group>
);
})}
</Stack>
)}
</Box>
<Field data-invalid={Boolean(errors.confirmPassword)}>
<FieldLabel>Confirm Password</FieldLabel>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
placeholder="Re-enter your password"
disabled={loading}
aria-invalid={Boolean(errors.confirmPassword)}
className="pr-10"
{...register("confirmPassword")}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showConfirmPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<FieldError errors={[errors.confirmPassword]} />
</Field>
</FieldGroup>
<PasswordInput
label="Confirm Password"
placeholder="Re-enter your password"
disabled={loading}
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
<Button type="submit" disabled={loading} size="lg" className="w-full">
{loading ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Creating...
</>
) : (
<>
Create Account
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?
<Button
type="button"
variant="link"
size="sm"
onClick={() => navigate("/login")}
className="h-auto p-0 font-semibold"
type="submit"
disabled={loading}
loading={loading}
size="lg"
color="edr-green"
fullWidth
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
>
Sign In
Create Account
</Button>
</p>
<Text size="sm" c="edr-muted" ta="center">
Already have an account?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
onClick={() => navigate("/login")}
>
Sign In
</Button>
</Text>
</Stack>
</form>
</AuthLayout>
);

View File

@@ -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<string, File | File[] | null>;
onDocumentFilesChange?: (
files: Record<string, File | File[] | null>,
) => void;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<TransporterStep>("vehicle");
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
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<FormData>({
const { register, handleSubmit, trigger, watch, control, formState: { errors } } = useForm<FormData>({
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: <Truck size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<TransporterStep, string> = {
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 (
<>
<div className="mb-8">
<button
type="button"
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
className="mb-4 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-4" />
Change account type
</button>
</Button>
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<Truck className="size-5" />}
active={step === "vehicle"}
completed={step !== "vehicle"}
/>
<StepIcon
icon={<UploadCloud className="size-5" />}
active={step === "documents"}
completed={step === "confirm"}
/>
<StepIcon
icon={<CheckCircle2 className="size-5" />}
active={step === "confirm"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{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`}
</p>
</div>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<form
onSubmit={(e) => e.preventDefault()}
className="flex flex-col gap-4"
>
{step === "vehicle" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.tinNumber)}>
<FieldLabel>TIN Number (10 digits)</FieldLabel>
<Input
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "vehicle" && (
<>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
aria-invalid={Boolean(errors.tinNumber)}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
<FieldError errors={[errors.tinNumber]} />
</Field>
<Field data-invalid={Boolean(errors.fanNumber)}>
<FieldLabel>FAN Number (16 digits)</FieldLabel>
<Input
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
aria-invalid={Boolean(errors.fanNumber)}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
<FieldError errors={[errors.fanNumber]} />
</Field>
</div>
</SimpleGrid>
<hr className="border-border" />
<Divider color="edr-border" />
<h3 className="text-sm font-semibold text-foreground">
Vehicle / Truck Information
</h3>
<Text fw={600} size="sm" c="edr-text">Vehicle / Truck Information</Text>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={Boolean(fieldState.error)}>
<FieldLabel>Truck Type</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger
className={cn(
"w-full",
fieldState.error ? "border-destructive!" : "",
)}
aria-invalid={Boolean(fieldState.error)}
>
<SelectValue placeholder="Select truck type..." />
</SelectTrigger>
<SelectContent>
{TRUCK_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name="truckType"
control={control}
render={({ field, fieldState }) => (
<Select
label="Truck Type"
placeholder="Select truck type..."
data={TRUCK_TYPES as unknown as string[]}
value={field.value || null}
onChange={(v) => field.onChange(v ?? "")}
error={fieldState.error?.message}
comboboxProps={{ withinPortal: true }}
/>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={Boolean(errors.plateNumber)}>
<FieldLabel>Plate Number{isCasoni ? " (Front)" : ""}</FieldLabel>
<Input
placeholder={isCasoni ? "AA-12345" : "AA-12345"}
aria-invalid={Boolean(errors.plateNumber)}
<SimpleGrid cols={2} spacing="md">
<TextInput
label={`Plate Number${isCasoni ? " (Front)" : ""}`}
placeholder="AA-12345"
error={errors.plateNumber?.message}
{...register("plateNumber")}
/>
<FieldError errors={[errors.plateNumber]} />
</Field>
{isCasoni && (
<Field data-invalid={Boolean(errors.plateNumber2)}>
<FieldLabel>Plate Number (Trailer)</FieldLabel>
<Input
{isCasoni ? (
<TextInput
label="Plate Number (Trailer)"
placeholder="AA-67890"
aria-invalid={Boolean(errors.plateNumber2)}
error={errors.plateNumber2?.message}
{...register("plateNumber2")}
/>
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
{!isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
) : (
<TextInput
label="Vehicle Model"
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
error={errors.vehicleModel?.message}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
</div>
)}
</SimpleGrid>
<div className="grid grid-cols-2 gap-4">
{isCasoni && (
<Field data-invalid={Boolean(errors.vehicleModel)}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input
<SimpleGrid cols={2} spacing="md">
{isCasoni && (
<TextInput
label="Vehicle Model"
placeholder="Isuzu FVR 2024"
aria-invalid={Boolean(errors.vehicleModel)}
error={errors.vehicleModel?.message}
{...register("vehicleModel")}
/>
<FieldError errors={[errors.vehicleModel]} />
</Field>
)}
<Field data-invalid={Boolean(errors.yearOfManufacturing)}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input
)}
<TextInput
label="Year of Manufacturing"
placeholder="2023"
maxLength={4}
aria-invalid={Boolean(errors.yearOfManufacturing)}
error={errors.yearOfManufacturing?.message}
{...register("yearOfManufacturing")}
/>
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : !uploadSetting ? (
<p className="text-sm text-muted-foreground text-center py-4">
No document requirements found for your account type.
</p>
) : (
<div className="flex flex-col gap-6">
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
</div>
)}
</>
)}
{step === "confirm" && (
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<h3 className="text-base font-semibold text-foreground">
Review your registration
</h3>
<p className="text-sm text-muted-foreground mt-1">
Confirm the details below before saving.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && (
<ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />
)}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
</div>
</div>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep}>
<ArrowLeft />
{step === "vehicle"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<div className="flex items-center gap-3">
{step === "documents" && (
<Button
type="button"
variant="outline"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
<Button
type="button"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Submitting...
</>
) : step === "documents" ? (
"Continue"
) : step === "confirm" ? (
"Submit Registration"
{step === "documents" && (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<>
Next Step
<ArrowRight />
</>
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "confirm" && (
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
<Text fw={600} c="edr-text">Review your registration</Text>
<Text size="sm" c="edr-muted" mt={4} mb="md">
Confirm the details below before saving.
</Text>
<SimpleGrid cols={2} spacing="sm">
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && <ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
</SimpleGrid>
</Box>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "vehicle" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
</div>
</div>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
@@ -447,37 +328,13 @@ export default function TransporterForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Box p={12} className="rounded-xl border border-edr-border bg-edr-card">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
{label}
</div>
<div className="mt-1 text-sm font-medium text-foreground">
</Text>
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
{value?.trim() ? value : "Not provided"}
</div>
</div>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
</Text>
</Box>
);
}

View File

@@ -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%]" },
}}
>
<div className="mb-6">
<div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<MailCheck className="size-6" />
</div>
<h2 className="text-2xl font-black tracking-tight">OTP Verification</h2>
<p className="mt-2 text-base text-muted-foreground">Enter the 6-digit code sent to:</p>
<div className="mt-3 rounded-xl border border-border bg-muted/50 px-4 py-2">
<p className="text-sm font-semibold">{maskedPhone}</p>
</div>
</div>
<Stack gap="xs" mb="lg">
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
<MailCheck size={22} color="var(--mantine-color-edr-green-6)" />
</Box>
<Box>
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
OTP Verification
</Text>
<Text fz={15} c="edr-muted" mt={4}>
Enter the 6-digit code sent to:
</Text>
<Box mt={8} p={12} bg="edr-slate-soft" className="rounded-xl border border-edr-border">
<Text size="sm" fw={600} c="edr-text">{maskedPhone}</Text>
</Box>
</Box>
</Stack>
{error && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
<Alert color="red" variant="light" radius="md" mb="md">
{error}
</div>
</Alert>
)}
{resentMessage && (
<div className="mb-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-2.5 text-sm text-blue-700">
<Alert color="blue" variant="light" radius="md" mb="md">
{resentMessage}
</div>
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup>
<Field data-invalid={Boolean(errors.code)}>
<FieldLabel>Verification Code</FieldLabel>
<Input
type="text"
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<TextInput
label="Verification Code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
disabled={verifying}
aria-invalid={Boolean(errors.code)}
className="h-14 text-center text-2xl font-black tracking-[10px]"
error={errors.code?.message}
styles={{
input: {
textAlign: "center",
fontSize: "24px",
fontWeight: 800,
letterSpacing: "10px",
height: "56px",
},
}}
{...register("code")}
/>
<div className="mt-1.5 flex items-center justify-between">
{errors.code ? (
<FieldError errors={[errors.code]} />
) : (
<p className="text-xs text-muted-foreground">Enter the OTP sent to your phone</p>
)}
<span className="text-xs text-muted-foreground">{otpValue.length}/6</span>
</div>
</Field>
</FieldGroup>
<Text size="xs" c="edr-muted" ta="right" mt={4}>
{otpValue.length}/6
</Text>
</Box>
<Button
type="submit"
disabled={verifying || otpValue.length !== 6}
size="lg"
className="w-full"
>
{verifying ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Verifying...
</>
) : (
<>
Verify Account
<ArrowRight data-icon="inline-end" />
</>
)}
</Button>
<Button
type="submit"
disabled={verifying || otpValue.length !== 6}
loading={verifying}
size="lg"
color="edr-green"
fullWidth
rightSection={!verifying ? <ArrowRight size={18} /> : undefined}
>
Verify Account
</Button>
<Button
type="button"
variant="outline"
onClick={handleResend}
disabled={resending}
className="w-full"
>
{resending ? (
<>
<Loader2 className="animate-spin" data-icon="inline-start" />
Sending...
</>
) : (
<>
<RotateCw data-icon="inline-start" />
Resend Code
</>
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Didn't receive the code?
<Button
type="button"
variant="link"
size="sm"
variant="outline"
onClick={handleResend}
className="h-auto p-0 font-semibold"
disabled={resending}
loading={resending}
fullWidth
leftSection={!resending ? <RotateCw size={16} /> : undefined}
>
Send again
Resend Code
</Button>
</p>
<Text size="sm" c="edr-muted" ta="center">
Didn't receive the code?{" "}
<Button
variant="transparent"
p={0}
h="auto"
c="edr-green.6"
fw={600}
fz="sm"
onClick={handleResend}
>
Send again
</Button>
</Text>
</Stack>
</form>
</AuthLayout>
);

View File

@@ -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<Record<string, HTMLInputElement | null>>({});
const documentsRef = useRef<HTMLDivElement>(null);
const [selectedFiles, setSelectedFiles] = useState<
Record<string, File | null>
>({});
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<string, File | File[] | null>) =>
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<string, File | null> = {};
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 (
<PageShell>
<PageHeader
booking={booking}
actions={
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
}
menuActions={{
onCancel: () => setCancelDialogOpen(true),
onSupport: () => navigate("/support"),
}}
/>
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
/>
<StatusHero booking={booking}>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<BodyGrid
left={
<>
{/* Complete your booking */}
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Complete your booking</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
Step {completeStep} of 3
</Text>
</Group>
<Stack gap={4}>
<StepLine index={1} done title="Cargo & route details" />
<StepLine
index={2}
active={!allDocsUploaded}
done={allDocsUploaded}
title={
allDocsUploaded
? "Required documents"
: "Upload required documents"
}
desc={`${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.`}
action={
<StepGhostButton
icon={<Upload size={16} color="#334155" />}
onClick={() =>
documentsRef.current?.scrollIntoView({
behavior: "smooth",
})
}
>
Add documents
</StepGhostButton>
}
/>
<StepLine
index={3}
active={allDocsUploaded}
title="Review & submit"
desc="Send your booking to EDR staff."
/>
</Stack>
<Button
fullWidth
mt="lg"
radius={10}
color="#0C1A2B"
leftSection={<Send size={16} />}
onClick={handleSubmitRequest}
disabled={submitMutation.isPending}
loading={submitMutation.isPending}
styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{submitMutation.isPending ? "Submitting…" : "Submit for review"}
</Button>
</SectionCard>
<ShipmentDetailsCard booking={booking} />
{/* Documents (uploadable) */}
<SectionCard ref={documentsRef}>
<Group justify="space-between" align="center" mb="md">
<Group gap={10} align="center">
<CardTitle>Documents</CardTitle>
<CountChip
uploaded={uploadedCount}
total={REQUIRED_DOC_FIELDS.length}
/>
</Group>
</Group>
{docError && (
<NoticeBanner
tone="red"
icon={<AlertCircle size={16} />}
className="mb-3"
>
{docError}
</NoticeBanner>
)}
<Box>
{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 (
<DocRow
key={doc.key}
last={i === REQUIRED_DOC_FIELDS.length - 1}
title={doc.label}
meta={
isUploaded
? (file?.name ?? "Uploaded")
: selected
? selected.name
: "Not added yet"
}
status={
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
<input
ref={(el) => {
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,
)
}
/>
<Group gap={6} wrap="nowrap">
<Button
variant="white"
radius={9}
leftSection={<Upload size={16} color="#334155" />}
onClick={() =>
fileInputRefs.current[doc.key]?.click()
}
styles={{
root: {
height: 34,
paddingInline: 13,
border: "1.5px solid #CBD5E1",
},
label: {
fontSize: 12.5,
fontWeight: 700,
color: "#334155",
},
}}
>
{selected ? "Change" : "Add"}
</Button>
{selected && (
<ActionIcon
variant="default"
radius={8}
w={34}
h={34}
onClick={() => handleFileSelect(doc.key, null)}
style={{ color: "#C0392B" }}
>
<X size={15} />
</ActionIcon>
)}
</Group>
</>
)
}
/>
);
})}
</Box>
{anyFileSelected && (
<Button
mt="md"
radius={10}
color="edr-green"
leftSection={
uploadMutation.isPending ? undefined : <Upload size={16} />
}
onClick={handleUploadAll}
disabled={uploadMutation.isPending}
loading={uploadMutation.isPending}
styles={{ root: { height: 44 }, label: { fontWeight: 700 } }}
>
{uploadMutation.isPending
? "Uploading…"
: "Upload selected documents"}
</Button>
)}
</SectionCard>
</>
}
right={
<>
<EstimateCard
pricing={pricing}
title="Estimated Cost"
chip="Not invoiced"
/>
<ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
</>
}
/>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Cancel booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to cancel <strong>{booking.reference}</strong>
? This action cannot be undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans, duplicate booking…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius="md"
onClick={() =>
cancelMutation.mutate(
cancelReason.trim() || "Cancelled by customer",
)
}
disabled={cancelMutation.isPending}
loading={cancelMutation.isPending}
leftSection={
!cancelMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, cancel
</Button>
</Group>
</Stack>
</Modal>
</PageShell>
);
}

View File

@@ -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 (
<PageShell>
<PageHeader
booking={booking}
actions={
canPay && (
<HeaderButton
green
icon={<CreditCard size={16} />}
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) ? (
<CancelledBanner
pillLabel={status === "REJECTED" ? "Rejected" : "Cancelled"}
title={`This booking was ${
status === "REJECTED" ? "rejected" : "cancelled"
} on ${fmtDate(booking.updatedAt)}.`}
subtitle={
status === "REJECTED"
? "This booking request has been rejected."
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
/>
) : (
<StatusHero booking={booking} />
)}
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Documents</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{booking.files.length} files
</Text>
</Group>
<Box>
{booking.files.map((file, i) => (
<DocRow
key={file.id}
last={i === booking.files!.length - 1}
title={file.name}
meta={file.code.replace(/_/g, " ")}
status="verified"
action={
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={16} />}
/>
}
/>
))}
</Box>
</SectionCard>
)}
<ActivityCard booking={booking} />
</>
}
right={
<>
<PaymentCard booking={booking} pricing={pricing} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
consignment
/>
<SupportCard />
</>
}
/>
</PageShell>
);
}

View File

@@ -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 (
<SectionCard>
<Group justify="space-between" align="center" pb={18}>
<CardTitle>Activity</CardTitle>
<Text fz="13px" fw={700} c="#0A6F4D">
Full history
</Text>
</Group>
<Box>
{events.map((e, i) => {
const first = i === 0;
const lastItem = i === events.length - 1;
return (
<Group key={i} gap={14} align="stretch" wrap="nowrap">
<Box
w={22}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<Box
style={{
width: 18,
height: 18,
borderRadius: 999,
backgroundColor: first ? "#0EA371" : "#D4E9DF",
boxShadow: first ? "0 0 0 4px #BFE8D4" : undefined,
}}
/>
{!lastItem && (
<Box
flex={1}
style={{
width: 2,
borderRadius: 999,
backgroundColor: "#D4E9DF",
}}
/>
)}
</Box>
<Box miw={0} flex={1} pb={lastItem ? 0 : 20}>
<Group gap={9} align="center">
<Text fz="14px" fw={800} c={first ? "#0A6F4D" : "#10202F"}>
{e.title}
</Text>
{first && (
<Box
component="span"
style={{
borderRadius: 999,
backgroundColor: "#ECF6F1",
border: "1px solid #CDEBDD",
padding: "3px 9px",
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.3,
color: "#0A6F4D",
}}
>
Current
</Box>
)}
</Group>
<Text mt={3} fz="12px" c="#9AA8B5">
{fmtDate(e.at)} · {e.note}
</Text>
</Box>
</Group>
);
})}
</Box>
</SectionCard>
);
}

View File

@@ -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<typeof useNavigate>;
}) {
const c = CONTRACT_CONFIG[booking.status as string];
if (!c) return null;
return (
<Paper
radius={16}
px={22}
py={20}
bg="#F1FAF6"
style={{ border: "1px solid #CFEBDD" }}
>
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap">
<Box
style={{
flexShrink: 0,
width: 46,
height: 46,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 13,
backgroundColor: "#fff",
border: "1px solid #CDEBDD",
color: "#0A6F4D",
}}
>
<FileSignature size={22} />
</Box>
<Box miw={0}>
<Box
component="span"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#0A6F4D",
padding: "4px 10px",
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.3,
color: "#fff",
textTransform: "uppercase",
}}
>
What's next
</Box>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{c.title}
</Text>
<Text mt={2} fz="13px" c="#5B6B7A">
{c.description}
</Text>
</Box>
</Group>
{c.buttonLabel && (
<Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
radius={10}
color="edr-green"
leftSection={<FileSignature size={18} />}
styles={{
root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
{c.buttonLabel}
</Button>
)}
</Group>
</Paper>
);
}

View File

@@ -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: <CheckCircle2 size={13} />,
},
ready: {
label: "Ready",
bg: "#EAF1FB",
color: "#2E5B96",
},
missing: {
label: "Not added",
bg: "#F1F4F7",
color: "#9AA8B5",
icon: <MinusCircle size={13} />,
},
};
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 (
<Group
gap={13}
align="center"
wrap="nowrap"
py={13}
style={{
borderBottom: last ? undefined : "1px solid #F2F5F8",
}}
>
<Box
style={{
flexShrink: 0,
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: tileBg,
color: tileFg,
}}
>
<FileText size={20} />
</Box>
<Box miw={0} flex={1}>
<Text truncate fz="13.5px" fw={700} c={missing ? "#6B7C8E" : "#10202F"}>
{title}
</Text>
<Text truncate fz="12px" c="#9AA8B5">
{meta}
</Text>
</Box>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
flexShrink: 0,
borderRadius: 999,
backgroundColor: pill.bg,
border: pill.border ? `1px solid ${pill.border}` : undefined,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: pill.color,
}}
>
{pill.icon}
{pill.label}
</Group>
{action}
</Group>
);
}
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 (
<Box
component="a"
href={href}
target="_blank"
rel="noopener noreferrer"
style={style}
>
{icon}
</Box>
);
}
return (
<Box component="span" style={style}>
{icon}
</Box>
);
}
export function CountChip({
uploaded,
total,
}: {
uploaded: number;
total: number;
}) {
const done = uploaded === total;
return (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 10px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: done ? "#ECF6F1" : "#F1F4F7",
color: done ? "#0A6F4D" : "#6B7C8E",
}}
>
{done && <CheckCircle2 size={13} />}
{uploaded} of {total} added
</Group>
);
}

View File

@@ -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 (
<div
className={`flex items-start gap-3 rounded-[14px] border p-4 ${className ?? ""}`}
style={{
borderColor: palette.border,
backgroundColor: palette.bg,
color: palette.color,
}}
>
<span className="mt-0.5 shrink-0">{icon}</span>
<div className="min-w-0">
{title && (
<Text fz="13.5px" fw={800} c={palette.color}>
{title}
</Text>
)}
<Text fz="13px" c={palette.color} className="leading-[1.45]">
{children}
</Text>
</div>
</div>
);
}
export function ActionRequiredBanner({
title,
children,
onAction,
}: {
title: string;
children: ReactNode;
onAction?: () => void;
}) {
return (
<div
className="flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-[#F4D9A8] px-[22px] py-5"
style={{ backgroundColor: "#FDF3E0" }}
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex shrink-0 items-center justify-center rounded-xl border border-[#F4D9A8]"
style={{ width: 44, height: 44, backgroundColor: "#fff", color: "#C77F12" }}
>
<AlertTriangle size={23} />
</div>
<div className="min-w-0">
<Text
fz="10.5px"
fw={800}
c="#B07A2A"
tt="uppercase"
className="tracking-[0.6px]"
>
Action required · You
</Text>
<Text mt={5} fz="15.5px" fw={700} c="#10202F">
{title}
</Text>
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
{children}
</Text>
</div>
</div>
{onAction && (
<Button
onClick={onAction}
radius={10}
color="#F2A516"
leftSection={<PencilLine size={18} color="#5A3D08" />}
styles={{
root: { height: 42, paddingInline: 18, flexShrink: 0 },
label: { fontSize: 13, fontWeight: 800, color: "#3D2A05" },
}}
>
Review & Resubmit
</Button>
)}
</div>
);
}
export function CancelledBanner({
pillLabel = "Cancelled",
title,
subtitle,
reason,
onRebook,
}: {
pillLabel?: string;
title: string;
subtitle: string;
reason?: string | null;
onRebook?: () => void;
}) {
return (
<Paper
radius={16}
p={20}
bg="#FCEEEA"
className="border border-[#F3C6BB]"
>
<Stack gap={14}>
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap" miw={0}>
<div
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F3C6BB]"
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#D93C15" }}
>
<XCircle size={24} />
</div>
<Box miw={0}>
<span
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
style={{ backgroundColor: "#D93C15" }}
>
{pillLabel}
</span>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{title}
</Text>
<Text mt={2} fz="13px" c="#6B7C8E">
{subtitle}
</Text>
</Box>
</Group>
{onRebook && (
<Button
onClick={onRebook}
radius={10}
color="edr-green"
styles={{
root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
Rebook shipment
</Button>
)}
</Group>
{reason && (
<div
className="flex items-start gap-[11px] rounded-xl border border-[#F3C6BB] p-[14px]"
style={{ backgroundColor: "#fff" }}
>
<StickyNote size={20} color="#B0341A" className="shrink-0" />
<div className="min-w-0">
<Text
fz="10.5px"
fw={700}
c="#B0341A"
tt="uppercase"
className="tracking-[0.5px]"
>
Reason for cancellation
</Text>
<Text mt={3} fz="13.5px" c="#3D2A26" className="leading-[1.45]">
{reason}
</Text>
</div>
</div>
)}
</Stack>
</Paper>
);
}
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) => (
<NoticeBanner
key={i}
tone="red"
icon={<AlertCircle size={18} />}
title="Something went wrong"
>
{m.error instanceof Error
? m.error.message
: "An unexpected error occurred."}
</NoticeBanner>
))}
</>
);
}

View File

@@ -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 (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
<Group gap={12} align="center" wrap="wrap">
<Text fz="26px" fw={800} c="#10202F">
{booking.reference}
</Text>
<span
className="inline-flex items-center gap-[7px] rounded-full px-3 py-1.5 text-xs font-bold"
style={{ backgroundColor: pillBg, border: `1px solid ${pillBorder}`, color: pillText }}
>
<span
className="shrink-0 rounded-full"
style={{ width: 7, height: 7, backgroundColor: dotColor }}
/>
{status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())}
</span>
<span className="inline-flex items-center gap-[6px] rounded-full bg-[#F1F4F7] px-[11px] py-1.5 text-xs font-bold text-[#475569]">
{isExport ? <ArrowUpRight size={14} /> : <ArrowDownLeft size={14} />}
{isExport ? "Export" : "Import"}
</span>
</Group>
<Text fz="14px" c="#6B7C8E">
{bookingSubtitle(booking)}
</Text>
</Stack>
<Group gap={8} wrap="nowrap" align="center">
{actions}
{hasMenu && (
<Menu shadow="md" radius={12} position="bottom-end">
<Menu.Target>
<ActionIcon
variant="default"
size={42}
radius={10}
aria-label="More options"
>
<MoreHorizontal size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={210}>
{menuActions!.onViewContract && (
<Menu.Item
leftSection={<FileText size={15} />}
onClick={menuActions!.onViewContract}
>
View contract
</Menu.Item>
)}
{menuActions!.onEdit && (
<Menu.Item
leftSection={<Edit2 size={15} />}
onClick={menuActions!.onEdit}
>
Edit
</Menu.Item>
)}
{menuActions!.onSupport && (
<Menu.Item
leftSection={<HelpCircle size={15} />}
onClick={menuActions!.onSupport}
>
Contact customer support
</Menu.Item>
)}
{menuActions!.onRebook && (
<Menu.Item
leftSection={<RefreshCw size={15} />}
onClick={menuActions!.onRebook}
>
Rebook similar schedule
</Menu.Item>
)}
{menuActions!.onCancel && (
<>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<XCircle size={15} />}
onClick={menuActions!.onCancel}
>
Cancel booking
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Group>
);
}
export function HeaderButton({
label,
icon,
onClick,
dark,
green,
disabled,
}: {
label: string;
icon: ReactNode;
onClick?: () => void;
dark?: boolean;
green?: boolean;
disabled?: boolean;
}) {
return (
<Button
onClick={onClick}
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
},
}}
>
{label}
</Button>
);
}

View File

@@ -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 (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
border: `1px solid ${border}`,
padding: "4px 10px",
fontSize: 11.5,
fontWeight: 700,
color,
}}
>
<Box
component="span"
style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: dot }}
/>
{label}
</Group>
);
}
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: <StatusPill status={booking.status as string} />,
};
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 (
<SectionCard p={22}>
<Box pb={8}>
<CardTitle>{title}</CardTitle>
</Box>
<Box>
{rows.map((r, i) => (
<Group
key={r.label}
justify="space-between"
align="center"
wrap="nowrap"
py="sm"
style={{
borderBottom:
i < rows.length - 1 ? "1px solid #F2F5F8" : undefined,
}}
>
<Text fz="13px" c="#9AA8B5">
{r.label}
</Text>
<Text
fz="13px"
fw={700}
c={r.muted ? "#9AA8B5" : "#10202F"}
fs={r.muted ? "italic" : undefined}
>
{r.value}
</Text>
</Group>
))}
</Box>
</SectionCard>
);
}

View File

@@ -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 (
<SectionCard>
<Group justify="space-between" align="center" pb={3}>
<CardTitle>Shipment Details</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#F1F4F7",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#475569",
}}
>
<FileText size={13} />
{booking.contractType === "RENEWAL"
? "Renewal contract"
: "New contract"}
</Group>
</Group>
<Box>
{rows.map((pair, i) => (
<Group
key={i}
gap={24}
align="flex-start"
wrap="nowrap"
py={13}
style={{
borderBottom:
i < rows.length - 1 ? "1px solid #F2F5F8" : undefined,
}}
>
{pair.map(([k, v]) => (
<Box key={k} miw={0} flex={1}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
{k}
</Text>
<Text truncate mt={4} fz="14px" fw={700} c="#10202F">
{v}
</Text>
</Box>
))}
</Group>
))}
</Box>
</SectionCard>
);
}

View File

@@ -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 (
<SectionCard p={28}>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Group gap={16} align="center" wrap="nowrap">
<div
className="flex items-center justify-center rounded-2xl shrink-0"
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }}
>
<HeroIcon size={26} />
</div>
<Box>
<Text fz="21px" fw={800} c="#10202F">
{cfg.title}
</Text>
<Text mt={5} fz="14px" c="#6B7C8E">
{cfg.description}
</Text>
</Box>
</Group>
<Group
gap={11}
align="center"
wrap="nowrap"
px="md"
py="sm"
className="rounded-[14px] border border-[#E6ECF2] bg-[#F7FAFC]"
>
<History size={22} color="#64748B" />
<Box>
<Text
fz="10.5px"
fw={700}
c="#9AA8B5"
tt="uppercase"
className="tracking-[0.6px]"
>
{chipLabel}
</Text>
<Text fz="13.5px" fw={700} c="#10202F">
{chipValue}
</Text>
</Box>
</Group>
</Group>
<Box my={26} h={1} w="100%" bg="#EEF2F6" />
{children ?? (
<ProgressTracker
current={cfg.stage}
tone={draft ? "ink" : "green"}
negative={negative}
/>
)}
</SectionCard>
);
}
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 */
<Box
className="overflow-x-auto"
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties}
>
<div className="flex items-start" style={{ minWidth: 440 }}>
{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 (
<div
key={stage.label}
className="flex flex-1 flex-col items-center gap-[10px]"
>
<div className="flex w-full items-center">
{/* left connector */}
<div
className="flex-1 rounded-full"
style={{
height: 3,
background:
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE",
}}
/>
{/* stage circle */}
<div
className="flex items-center justify-center rounded-full shrink-0"
style={{
width: 40,
height: 40,
backgroundColor: circleBg,
border: circleBorder,
boxShadow: circleShadow,
}}
>
{state === "done" ? (
<Check size={16} color="#fff" />
) : state === "active" ? (
<Icon size={16} color="#fff" />
) : null}
</div>
{/* right connector */}
<div
className="flex-1 rounded-full"
style={{
height: 3,
background:
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE",
}}
/>
</div>
<Text
fz="13.5px"
fw={state === "active" ? 800 : 700}
ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"}
>
{stage.label}
</Text>
<Text
fz="11.5px"
fw={state === "active" ? 700 : 500}
ta="center"
c={state === "active" ? activeSub : "#9AA8B5"}
>
{state === "done"
? "Completed"
: state === "active"
? negative
? "Stopped"
: "In progress"
: "Pending"}
</Text>
</div>
);
})}
</div>
</Box>
);
}

View File

@@ -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 (
<Box
style={{
flexShrink: 0,
width: 26,
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
...style,
}}
>
{done ? <Check size={16} strokeWidth={3} /> : index}
</Box>
);
}
export function StepLine({
index,
title,
desc,
action,
done,
active,
}: {
index: number;
title: string;
desc?: string;
action?: ReactNode;
done?: boolean;
active?: boolean;
}) {
if (active) {
return (
<Group
gap={14}
align="center"
justify="space-between"
wrap="nowrap"
px={14}
py={12}
style={{ borderRadius: 12, backgroundColor: "#F4F7FA" }}
>
<Group gap={14} align="center" wrap="nowrap" miw={0}>
<StepCircle index={index} active />
<Box miw={0}>
<Text fz="14px" fw={800} c="#10202F">
{title}
</Text>
{desc && (
<Text mt={2} fz="12.5px" c="#6B7C8E">
{desc}
</Text>
)}
</Box>
</Group>
{action}
</Group>
);
}
return (
<Group gap={14} align="center" wrap="nowrap" px={12} py={10}>
<StepCircle index={index} done={done} />
<Text fz="14px" fw={700} c={done ? "#6B7C8E" : "#9AA8B5"}>
{title}
</Text>
</Group>
);
}
export function StepGhostButton({
children,
onClick,
icon,
}: {
children: ReactNode;
onClick?: () => void;
icon?: ReactNode;
}) {
return (
<Button
variant="white"
radius={9}
onClick={onClick}
leftSection={icon}
styles={{
root: {
height: 34,
paddingInline: 14,
border: "1.5px solid #CBD5E1",
flexShrink: 0,
},
label: { fontSize: 12.5, fontWeight: 700, color: "#334155" },
}}
>
{children}
</Button>
);
}

View File

@@ -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 (
<Paper radius={20} p={22} bg="#0C1A2B">
<Group gap={12} align="center" wrap="nowrap">
<Box
style={{
width: 42,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#16273A",
}}
>
<MessageSquare size={20} color="#fff" />
</Box>
<Box>
<Text fz="15px" fw={800} c="#fff">
Need help?
</Text>
<Text fz="12px" c="#9AA8B5">
EDR operations team
</Text>
</Box>
</Group>
<Text mt={14} fz="13px" c="#C4D0DB" style={{ lineHeight: 1.45 }}>
Questions about this shipment, documents, or delivery? Our operations
team can help.
</Text>
<Group gap={10} mt="md" wrap="nowrap">
<Button
flex={1}
color="edr-green"
radius={10}
leftSection={<MessageSquare size={16} />}
styles={{ root: { height: 44 }, label: { fontWeight: 700 } }}
>
Contact
</Button>
<Button
onClick={onCancel}
radius={10}
color="#16273A"
leftSection={
onCancel ? <XCircle size={16} /> : <FileText size={16} />
}
styles={{
root: { height: 44, paddingInline: 16 },
label: { fontWeight: 700, color: "#fff" },
}}
>
{onCancel ? "Cancel" : "Report"}
</Button>
</Group>
</Paper>
);
}

View File

@@ -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 (
<Box mih="100vh">
<Box mx="auto" w="100%" px={32} pt={28} pb={32}>
<Stack gap="lg">{children}</Stack>
</Box>
</Box>
);
}
export function BodyGrid({ left, right }: { left: ReactNode; right: ReactNode }) {
return (
<Flex
direction={{ base: "column", lg: "row" }}
align={{ base: "stretch", lg: "flex-start" }}
gap={24}
>
{/* Left column: capped at 700px on tablet so it doesn't bleed edge-to-edge */}
<Box flex={1} miw={0} maw={700} className="flex flex-col gap-6 lg:max-w-none">
{left}
</Box>
<Box
w={360}
maw="100%"
className="flex flex-col gap-6 shrink-0"
>
{right}
</Box>
</Flex>
);
}
interface SectionCardProps extends PaperProps {
children: ReactNode;
ref?: Ref<HTMLDivElement>;
}
export function SectionCard({ children, ref, ...props }: SectionCardProps) {
return (
<Paper ref={ref} radius={20} p="lg" withBorder bg="white" {...props}>
{children}
</Paper>
);
}
export function CardTitle({ children }: { children: ReactNode }) {
return (
<Text fz="16px" fw={800} c="#10202F">
{children}
</Text>
);
}

View File

@@ -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 (
<Stack gap={11}>
{items.map((it) => (
<Group key={it.label} justify="space-between" wrap="nowrap">
<Text fz="13px" c="#6B7C8E">
{it.label}
</Text>
<Text fz="13px" fw={600} c="#10202F">
{it.value}
</Text>
</Group>
))}
</Stack>
);
}
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
export function EstimateCard({
pricing,
title,
chip,
}: {
pricing: Pricing;
title: string;
chip: string;
}) {
const hasItems = priceLineItems(pricing).length > 0;
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>{title}</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#F1F4F7",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#6B7C8E",
}}
>
<Clock size={13} />
{chip}
</Group>
</Group>
<Box mt={12}>
<Group gap={8} align="flex-end" wrap="nowrap">
<Text fz="26px" fw={800} c="#10202F" lh={1.1}>
{priceTotal(pricing)}
</Text>
<Box
component="span"
mb={4}
style={{
borderRadius: 6,
backgroundColor: "#F1F4F7",
padding: "3px 7px",
fontSize: 11,
fontWeight: 700,
color: "#6B7C8E",
}}
>
est.
</Box>
</Group>
<Text mt={4} fz="12.5px" c="#9AA8B5">
A firm price is confirmed after EDR reviews your booking.
</Text>
</Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
Estimated total
</Text>
<Text fz="15px" fw={800} c="#10202F">
{priceTotal(pricing)}
</Text>
</Group>
</>
)}
</SectionCard>
);
}
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 (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
color: paid ? "#0A6F4D" : "#9A5B00",
border: paid ? "1px solid #CDEBDD" : undefined,
}}
>
{paid && <CheckCircle2 size={13} />}
{paid
? "Paid"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
</Group>
</Group>
<Box mt={12}>
<Text fz="26px" fw={800} c="#10202F">
{total}
</Text>
{paid && (
<Text mt={4} fz="12.5px" c="#9AA8B5">
Paid · {fmtDate(booking.updatedAt)}
</Text>
)}
</Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
Total
</Text>
<Text fz="15px" fw={800} c="#0A6F4D">
{total}
</Text>
</Group>
</>
)}
<Button
fullWidth
mt={16}
variant="default"
radius={10}
leftSection={<FileText size={17} color="#475569" />}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }}
>
Download invoice
</Button>
</SectionCard>
);
}

View File

@@ -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 hasnt 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" },
];

View File

@@ -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 (
<Center mih={400} p="xl">
<Stack align="center" gap="md">
<Loader color="edr-green" />
<Text size="sm" c="dimmed">
Loading booking details
</Text>
</Stack>
</Center>
);
}
if (isError || !booking) {
return (
<PageShell>
<SectionCard>
<Stack align="center" gap={8} py={48} ta="center">
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={30} />
</Box>
<Text mt={4} fz="20px" fw={800} c="#10202F">
{isError ? "Failed to load booking" : "Booking not found"}
</Text>
{isError && (
<Text size="sm" c="dimmed">
{error instanceof Error
? error.message
: "An unexpected error occurred."}
</Text>
)}
</Stack>
</SectionCard>
</PageShell>
);
}
if (isDraftLike(booking.status)) {
return (
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
);
}
return <ReadonlyBookingView booking={booking} />;
}

View File

@@ -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}`;
}

View File

@@ -1,17 +1,19 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Plus,
Search,
Truck,
} from "lucide-react";
ActionIcon,
Box,
Button,
Card,
Group,
Menu,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowUpDown, Download, Filter, MoreVertical, Package, Plus } from "lucide-react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
@@ -20,146 +22,276 @@ import {
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@edr/ui-common";
// ── Status badge ──────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<string, { bg: string; dot: string; color: string; label: string }> = {
DRAFT: { bg: "#F1F4F7", dot: "#94A3B8", color: "#475569", label: "Draft" },
REVIEWING: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Reviewing" },
AWAITING_PAYMENT: { bg: "#FDF3E0", dot: "#F2A516", color: "#9A5B00", label: "Awaiting Payment" },
CONFIRMED: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "Confirmed" },
IN_TRANSIT: { bg: "#ECF6F1", dot: "#0EA371", color: "#0A6F4D", label: "In Transit" },
DELIVERED: { bg: "#E9F0F8", dot: "#3B6FB0", color: "#2E5B96", label: "Delivered" },
CANCELLED: { bg: "#FBEAE7", dot: "#C0392B", color: "#C0392B", label: "Cancelled" },
};
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? {
bg: "#F1F4F7",
dot: "#94A3B8",
color: "#475569",
label: status.replace(/_/g, " "),
};
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: cfg.bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: cfg.dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: cfg.color, whiteSpace: "nowrap" }}>
{cfg.label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
status,
id,
onNavigate,
}: {
status: string;
id: string;
onNavigate: (path: string) => void;
}) {
if (status === "DRAFT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Continue
</Button>
);
}
if (status === "AWAITING_PAYMENT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
style={{ backgroundColor: "var(--mantine-color-edr-accent-0)", color: "#fff" }}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Pay
</Button>
);
}
if (status === "IN_TRANSIT") {
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
Track
</Button>
);
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={() => onNavigate(`/bookings/${id}`)}
>
View
</Button>
);
}
// ── Column header label ───────────────────────────────────────────────────────
function ColHeader({ label }: { label: string }) {
return (
<Text
fz={11}
fw={700}
c="edr-muted"
style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }}
>
{label}
</Text>
);
}
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
// ── Main component ────────────────────────────────────────────────────────────
export default function MyBookings() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [searchTerm, setSearchTerm] = useState("");
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions(),
);
const { data, isLoading, isError } = useQuery(api.bookings.list.queryOptions());
const bookings = data?.items ?? [];
const filteredData = useMemo(() => {
return bookings.filter((b) => {
const term = searchTerm.toLowerCase();
return (
b.reference.toLowerCase().includes(term) ||
(b.originYard?.label ?? b.originYard?.code ?? "").toLowerCase().includes(term) ||
(b.destinationYard?.label ?? b.destinationYard?.code ?? "").toLowerCase().includes(term) ||
b.status.toLowerCase().includes(term)
);
});
}, [bookings, searchTerm]);
const total = filteredData.length;
const total = bookings.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
const activeCount = useMemo(() => {
return bookings.filter(
(b) => b.status === "CONFIRMED" || b.status === "IN_TRANSIT",
).length;
}, [bookings]);
const pendingCount = useMemo(() => {
return bookings.filter((b) => b.status === "DRAFT").length;
}, [bookings]);
const paginatedData = useMemo(() => bookings.slice(start, end), [bookings, start, end]);
const columns: ColumnDef<Freight.IBooking>[] = [
{
accessorKey: "reference",
header: "Reference",
id: "booking",
size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const booking = row.original;
const b = row.original;
const cargoLabel =
b.freightType === "BULK"
? "Bulk Cargo"
: b.freightType === "BREAK_BULK"
? "Break Bulk"
: "Cargo";
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-foreground">{booking.reference}</p>
<p className="text-sm text-muted-foreground">{booking.scheduledDate ?? booking.createdAt}</p>
</div>
</div>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 36,
height: 36,
borderRadius: 9,
flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-soft-0)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Package size={18} color="var(--mantine-color-edr-green-7)" strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
{b.reference}
</Text>
<Text fz={12} c="edr-muted">
{cargoLabel}
</Text>
</Box>
</Group>
);
},
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}</span>
<ArrowRight className="text-muted-foreground" />
<span>{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
size: 196,
meta: hMeta,
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
const containerType = b.containers?.[0]?.type ?? null;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = b.scheduledDate ?? b.createdAt ?? "";
return (
<div className="text-sm text-muted-foreground">
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
<p className="text-xs text-muted-foreground">
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
</p>
</div>
<Box>
<Text fz={13} fw={600} c="edr-text">
{origin} {dest}
</Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box>
);
},
},
{
id: "transportMode",
header: "Transport",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</span>
),
},
{
accessorKey: "status",
header: "Status",
id: "status",
size: 190,
meta: hMeta,
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "amount",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & { totalAmount?: number; amount?: number };
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
</Text>
);
}
return (
<Text fz={14} fw={700} c="edr-text">
ETB {amount.toLocaleString()}
</Text>
);
},
},
{
id: "actions",
size: 40,
meta: hMeta,
header: () => null,
cell: ({ row }) => {
const booking = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/bookings/${booking.id}`)}
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<PrimaryAction status={booking.status} id={booking.id} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<Eye />
View
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View Details
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
@@ -168,148 +300,108 @@ export default function MyBookings() {
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">
My Bookings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
View and manage your freight booking requests.
</p>
</div>
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
Bookings
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Manage every cargo booking from draft to delivery.
</Text>
</Box>
<Group gap={12}>
<Button variant="default" radius="md" leftSection={<Download size={16} />}>
Export
</Button>
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New Booking
</Button>
</Group>
</Group>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
placeholder="Search bookings..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8!"
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
New Booking
</Button>
</Link>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{bookings.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Active Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{activeCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pending Approval</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Recent Requests</CardTitle>
<CardDescription>
A list of your recent freight bookings and their statuses.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
{/* Toolbar */}
<Group
justify="flex-end"
gap={8}
px={20}
py={14}
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<ArrowUpDown size={14} />}
>
Sort
</Button>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<Filter size={14} />}
>
Filter
</Button>
</CardHeader>
</Group>
<CardContent className="px-0">
{total === 0 && dataTableStatus === "success" ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-sm font-semibold text-foreground">No bookings found</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."}
</p>
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
{/* Empty state */}
{total === 0 && dataTableStatus === "success" ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
No bookings yet
</Text>
<Text size="xs" c="edr-muted" maw={320}>
You haven't made any booking requests yet. Create your first one to get started.
</Text>
<Button
component={Link}
to="/bookings/new"
size="sm"
color="edr-green"
radius="md"
mt="md"
leftSection={<Plus size={15} />}
>
Create first booking
</Button>
</Stack>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
DRAFT: "bg-amber-100 text-amber-700",
CONFIRMED: "bg-primary/10 text-primary",
IN_TRANSIT: "bg-muted text-foreground",
DELIVERED: "bg-primary/10 text-primary",
CANCELLED: "bg-destructive/10 text-destructive",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-muted text-muted-foreground"}`}
>
{status.replace(/_/g, ' ')}
</span>
</Stack>
</Box>
);
}

View File

@@ -1,18 +1,12 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import {
BookingFormInputValues,
STEPS,
@@ -28,6 +22,7 @@ import {
Step2ServiceType,
Step4Route,
Step5CargoDetails,
StepDocuments,
Step8Review,
} from "./new-booking-form/steps";
@@ -40,8 +35,25 @@ export default function NewBookingPage() {
);
const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) =>
api.bookings.create.call(payload),
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
return booking;
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
@@ -105,14 +117,6 @@ export default function NewBookingPage() {
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findCargoTypeId = (name: string): string | undefined => {
for (const group of cargoTree) {
const child = group.children?.find((c) => c.name === name);
if (child) return child.id;
}
return undefined;
};
const findContainerCargoTypeId = (): string => {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
@@ -138,7 +142,7 @@ export default function NewBookingPage() {
const cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: selectedChild?.id ?? "";
: (selectedChild?.id ?? "");
const cargoFreeText =
data.cargoType === "container"
@@ -205,89 +209,161 @@ export default function NewBookingPage() {
});
return (
<form
id="new-booking-form"
className="flex flex-col"
onSubmit={handleSubmit}
<Box
style={{
padding: "28px 0 0",
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
display: "flex",
flexDirection: "column",
}}
>
<div className="sticky top-0 z-20">
<div className="mx-auto max-w-4xl space-y-3 px-6 pt-4">
<StepIndicator step={step} />
</div>
</div>
{/* ── Page header ─────────────────────────────────────────────────── */}
<Group
justify="space-between"
px="24px"
align="flex-end"
wrap="wrap"
gap="md"
mb="lg"
>
<Box>
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
New Booking
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Fill in the details below to book your freight shipment.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate("/bookings")}
>
Back to Bookings
</Button>
</Group>
<div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8">
{createMutation.isError && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="font-semibold">Failed to save draft</p>
<p className="mt-1 text-red-600">
<form
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
>
{/* Step indicator */}
<Box>
<Box className="mx-auto max-w-5xl" style={{ paddingInline: "16px" }}>
<StepIndicator step={step} />
</Box>
</Box>
{/* Step content */}
<Box flex={1}>
<Box className="mx-auto max-w-5xl" style={{ padding: "32px 24px" }}>
{createMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</p>
</div>
</div>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<Step8Review form={form} setStep={setStep} direction={direction} />
)}
</div>
</div>
</Text>
</Alert>
)}
<div className="sticky bottom-0 z-20 border-t border-border bg-background px-6 py-4">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<Button
type="button"
variant="outline"
onClick={() =>
setStep((currentStep) => Math.max(1, currentStep - 1))
}
disabled={step === 1}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Button>
{step < STEPS.length ? (
<Button type="button" onClick={handleContinue}>
Continue
<ChevronRight />
</Button>
) : (
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
form={form}
direction={direction}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && <StepDocuments form={form} />}
{step === 6 && (
<Step8Review
form={form}
setStep={setStep}
direction={direction}
/>
)}
</Box>
</Box>
{/* Navigation footer */}
<Box
style={{
position: "sticky",
bottom: 0,
zIndex: 20,
borderTop: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "rgba(255,255,255,0.94)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
padding: "16px 24px",
marginTop: "auto",
}}
>
<Group justify="space-between" className="mx-auto max-w-4xl">
<Button
type="submit"
form="new-booking-form"
disabled={createMutation.isPending}
type="button"
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1}
>
{createMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Check />
)}
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
Back
</Button>
)}
</div>
</div>
</form>
{step < STEPS.length ? (
<Button
type="button"
color="edr-green"
radius="md"
rightSection={<ChevronRight size={16} />}
onClick={handleContinue}
>
Continue
</Button>
) : (
<Button
type="submit"
form="new-booking-form"
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
)}
</Group>
</Box>
</form>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import { Fragment } from "react";
import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
export function StepIndicator({ step }: { step: number }) {
@@ -9,29 +9,73 @@ export function StepIndicator({ step }: { step: number }) {
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${
step > item.id
? "bg-primary text-primary-foreground"
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
transition: "all 0.2s",
...(step > item.id
? {
backgroundColor: "var(--mantine-color-edr-green-5)",
color: "#fff",
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
? "border-2 border-primary text-primary"
: "bg-muted text-muted-foreground"
}`}
? {
border: "2.5px solid var(--mantine-color-edr-green-5)",
color: "var(--mantine-color-edr-green-7)",
backgroundColor: "#fff",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
}
: {
backgroundColor: "#fff",
color: "var(--mantine-color-edr-muted-0)",
border: "2px solid var(--mantine-color-edr-border-0)",
}),
}}
>
{step > item.id ? <Check className="h-3.5 w-3.5" /> : item.id}
{step > item.id ? (
<Check style={{ width: 13, height: 13 }} />
) : (
item.id
)}
</div>
<span
className={`hidden text-[10px] font-medium lg:block ${
step >= item.id ? "text-foreground" : "text-muted-foreground"
}`}
style={{
fontSize: 10,
fontWeight: 500,
display: "none",
transition: "color 0.2s",
color:
step >= item.id
? "var(--mantine-color-edr-text-0)"
: "var(--mantine-color-edr-muted-0)",
}}
className="lg:!block"
>
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
<div
className={`mx-1 h-0.5 flex-1 rounded-full transition-colors ${
step > item.id ? "bg-primary" : "bg-border"
}`}
style={{
flex: 1,
height: 2,
borderRadius: 999,
margin: "0 6px",
marginBottom: 14,
transition: "background-color 0.3s",
backgroundColor:
step > item.id
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-0)",
}}
/>
)}
</Fragment>

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
@@ -22,9 +23,61 @@ export const STEPS = [
{ id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Review & Submit", short: "Submit" },
{ id: 5, label: "Documents", short: "Documents" },
{ id: 6, label: "Review & Submit", short: "Submit" },
] as const;
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
function docField(
fileKey: string,
fileLabel: string,
order: number,
): Freight.IFileUploadField {
return {
id: fileKey,
settingId: "booking_documents",
createdAt: DOC_SETTING_TS,
updatedAt: DOC_SETTING_TS,
deletedAt: null,
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
order,
};
}
export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
id: "booking_documents",
createdAt: DOC_SETTING_TS,
updatedAt: DOC_SETTING_TS,
deletedAt: null,
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),
docField("packing_list", "Packing List", 2),
docField("certificate_of_origin", "Certificate of Origin", 3),
docField("letter_of_credit", "Letter of Credit / LC", 4),
],
};
export type BookingDocuments = Record<string, File | File[] | null>;
export const bookingFormSchema = z
.object({
contractType: z.enum(["new", "renewal"], "Select a contract type."),
@@ -78,6 +131,7 @@ export const bookingFormSchema = z
}),
),
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
termsAccepted: z.boolean(),
})
@@ -187,6 +241,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
documents: {},
notes: "",
termsAccepted: false,
};
@@ -215,7 +270,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers",
"consolidationEnabled",
],
5: ["notes", "termsAccepted"],
5: ["documents"],
6: ["notes", "termsAccepted"],
};
export type RouteDirection = "import" | "export" | "domestic" | null;

View File

@@ -1,31 +1,16 @@
import type { ReactNode } from "react";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
XCircle,
} from "lucide-react";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
import { cn } from "@/lib/utils";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react";
import { Alert, Select, Text, Title } from "@mantine/core";
import type { BookingFormInputValues } from "./schema";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />;
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
{error.message}
</Text>
);
}
export function OptionCard({
@@ -44,16 +29,17 @@ export function OptionCard({
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled
? "cursor-not-allowed border-border bg-muted opacity-60"
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
: selected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-primary/40"
}`}
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
>
{selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary">
<Check className="h-3 w-3 text-primary-foreground" />
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-white" />
</span>
)}
{children}
@@ -68,34 +54,25 @@ export function AlertBox({
tone: "warning" | "error" | "success" | "info";
children: ReactNode;
}) {
const styles = {
warning: "bg-amber-50 border-amber-200 text-amber-800",
error: "bg-red-50 border-red-200 text-red-800",
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
info: "bg-sky-50 border-sky-200 text-sky-800",
const map: Record<string, { color: string; icon: ReactNode }> = {
warning: { color: "yellow", icon: <AlertTriangle size={16} /> },
error: { color: "red", icon: <XCircle size={16} /> },
success: { color: "teal", icon: <CheckCircle2 size={16} /> },
info: { color: "blue", icon: <Info size={16} /> },
};
const icons = {
warning: <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />,
error: <XCircle className="h-4 w-4 shrink-0 text-red-500" />,
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />,
info: <Info className="h-4 w-4 shrink-0 text-sky-500" />,
};
const { color, icon } = map[tone];
return (
<div
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${styles[tone]}`}
>
{icons[tone]}
<div>{children}</div>
</div>
<Alert color={color} icon={icon} radius="md" fz="sm">
{children}
</Alert>
);
}
export function StepLabel({ children }: { children: ReactNode }) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
{children}
</p>
</Text>
);
}
@@ -108,8 +85,12 @@ export function StepHeader({
}) {
return (
<div>
<h2 className="text-xl font-bold tracking-tight">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
);
}
@@ -120,46 +101,27 @@ export function SelectField({
label,
placeholder,
disabled,
children,
data,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
label: string;
placeholder: string;
disabled?: boolean;
children: ReactNode;
data: string[] | { value: string; label: string }[];
}) {
return (
<Field data-invalid={Boolean(error)}>
<FieldLabel>{label}</FieldLabel>
<Select
value={String(field.value)}
onValueChange={field.onChange}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full ", error ? "border-destructive!" : "")}
aria-invalid={Boolean(error)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
<FieldError errors={[error]} />
</Field>
);
}
export { SelectItem };
export function SelectOptions({ options }: { options: readonly string[] }) {
return (
<>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</>
<Select
label={label}
placeholder={placeholder}
disabled={disabled}
data={data}
value={String(field.value) || null}
onChange={(v) => field.onChange(v ?? "")}
onBlur={field.onBlur}
error={error?.message}
radius="md"
allowDeselect={false}
/>
);
}

View File

@@ -0,0 +1,82 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
function countAttached(documents: BookingDocuments): number {
return BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
}
export function StepDocuments({ form }: { form: BookingForm }) {
const documents = (form.watch("documents") ?? {}) as BookingDocuments;
const attached = countAttached(documents);
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepHeader
title="Shipment Documents"
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
/>
<Group
gap={10}
align="center"
wrap="nowrap"
className="rounded-xl"
style={{
border: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "var(--mantine-color-gray-0)",
padding: "12px 16px",
}}
>
<Box
style={{
flexShrink: 0,
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
backgroundColor: attached === total ? "#ECF6F1" : "#EAF1FB",
color: attached === total ? "#0A6F4D" : "#2E5B96",
}}
>
{attached === total ? <CheckCircle2 size={16} /> : `${attached}/${total}`}
</Box>
<Text size="sm" c="dimmed">
{attached === 0
? "All documents are optional here — you can upload them later from the booking page."
: `${attached} of ${total} attached. You can finish the rest later from the booking page.`}
</Text>
</Group>
<Controller
name="documents"
control={form.control}
render={({ field }) => (
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={(field.value ?? {}) as BookingDocuments}
onChange={(value) => field.onChange(value)}
/>
)}
/>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import {
BookingFormInputValues,
MOCK_VALID_CONTRACTS,
@@ -11,15 +10,10 @@ import {
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType");
@@ -36,7 +30,7 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
name="contractType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
@@ -46,11 +40,11 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
form.setValue("previousContractRef", "");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
@@ -66,14 +60,14 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
</div>
)}
/>
@@ -88,13 +82,8 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
error={fieldState.error}
label="Previous Contract Reference Number"
placeholder="Select a contract..."
>
{MOCK_VALID_CONTRACTS.map((ref) => (
<SelectItem key={ref} value={ref}>
{ref}
</SelectItem>
))}
</SelectField>
data={MOCK_VALID_CONTRACTS}
/>
)}
/>
{previousContractRef && (

View File

@@ -1,15 +1,11 @@
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { Badge, Switch, TextInput } from "@mantine/core";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");
@@ -35,34 +31,18 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
form.setValue("customsClearingEnabled", false, {
shouldDirty: true,
});
form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
form.setValue("customsClearingEnabled", false, { shouldDirty: true });
} else if (serviceType === "rail_forwarding") {
form.setValue(
"firstMile",
{
enabled: false,
pickUpAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
form.setValue(
"lastMile",
{
enabled: false,
deliveryAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
}
}, [serviceType, form]);
@@ -80,7 +60,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="serviceType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={serviceType === "rail"}
@@ -90,11 +70,11 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">Rail Transport Only</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Rail transport along the EDR corridor, with optional
first/last mile trucking.
</p>
<Badge className="mt-2 bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
<Badge color="indigo" variant="light" mt="xs" size="sm">
Option A
</Badge>
</OptionCard>
@@ -103,26 +83,27 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
selected={serviceType === "rail_forwarding"}
onClick={() => field.onChange("rail_forwarding")}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Logistics</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
</p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
<Badge color="edr-green" variant="light" mt="xs" size="sm">
Option B
</Badge>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
</div>
)}
/>
{showServiceSections && (
<div className="divide-y divide-border rounded-xl border border-border">
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{/* First Mile */}
<div className="p-4">
<Controller
name="firstMile.enabled"
@@ -130,12 +111,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile - Pick-up
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">First Mile Pick-up</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
@@ -143,7 +122,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
@@ -152,6 +132,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
});
}
}}
color="edr-green"
/>
</div>
)}
@@ -161,19 +142,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Pick-up address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
{/* Last Mile */}
<div className="p-4">
<Controller
name="lastMile.enabled"
@@ -181,12 +162,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile - Delivery
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">Last Mile Delivery</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
@@ -194,7 +173,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
@@ -206,6 +186,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
});
}
}}
color="edr-green"
/>
</div>
)}
@@ -215,19 +196,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Delivery address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
{/* Equipment Return */}
{lastMileEnabled && (
<div className="p-4">
<Controller
@@ -235,23 +216,22 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
onChange={(e) => {
field.onChange(
value ? "with_return" : "without_return",
e.currentTarget.checked ? "with_return" : "without_return",
);
}}
color="edr-green"
/>
</div>
)}
@@ -259,6 +239,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
)}
{/* Customs Clearing */}
<div className="p-4">
<Controller
name="customsClearingEnabled"
@@ -266,12 +247,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-sm font-medium">Customs Clearing Service</p>
<p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on your
behalf.
</p>
@@ -279,7 +258,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
@@ -10,11 +10,7 @@ import {
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step4Route({
form,
@@ -30,26 +26,29 @@ export function Step4Route({
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
label: y.name,
country: y.country,
}));
return referenceData.yard.map((y) => ({ value: y.name, label: y.name }));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
}, [referenceData]);
const originData = useMemo(
() => yardOptions.filter((o) => o.value !== destinationYard),
[yardOptions, destinationYard],
);
const destData = useMemo(
() => yardOptions.filter((o) => o.value !== originYard),
[yardOptions, originYard],
);
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
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",
domestic: "bg-gray-100 text-gray-600 border-gray-200",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)",
@@ -85,15 +84,11 @@ export function Step4Route({
<SelectField
field={field}
error={fieldState.error}
label="Origin Yard*"
label="Origin Yard *"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={destinationYard}
/>
</SelectField>
data={originData}
/>
)}
/>
<Controller
@@ -106,12 +101,8 @@ export function Step4Route({
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={originYard}
/>
</SelectField>
data={destData}
/>
)}
/>
</div>
@@ -126,7 +117,7 @@ export function Step4Route({
</div>
)}
{direction && direction != "domestic" && (
{direction && direction !== "domestic" && (
<Controller
name="shippingLine"
control={form.control}
@@ -136,19 +127,15 @@ export function Step4Route({
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
>
{shippingLineOptions.map((sl) => (
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
data={shippingLineOptions}
/>
)}
/>
)}
<Separator />
<div className="space-y-0 divide-y divide-border">
<Divider />
<div className="divide-y divide-gray-200">
<Controller
name="isHazardous"
control={form.control}
@@ -158,12 +145,16 @@ export function Step4Route({
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
@@ -176,13 +167,17 @@ export function Step4Route({
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
@@ -193,48 +188,18 @@ export function Step4Route({
function LoadingSkeleton() {
return (
<div className="space-y-4 rounded-xl border border-border p-4">
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<Stack gap={8}>
<Skeleton height={12} w={80} radius="sm" />
<Skeleton height={40} radius="md" />
</Stack>
<Stack gap={8}>
<Skeleton height={12} w={96} radius="sm" />
<Skeleton height={40} radius="md" />
</Stack>
</div>
<Skeleton className="h-8 w-full" />
<Skeleton height={32} radius="md" />
</div>
);
}
function YardSelectOptions({
options,
excludeValue,
}: {
options: Array<{ value: string; label: string; country: string }>;
excludeValue: string;
}) {
if (options.length === 0) {
return (
<SelectItem value="__yards_empty" disabled>
No yards available
</SelectItem>
);
}
const availableOptions = options.filter(
(option) => option.value !== excludeValue,
);
return (
<>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</>
);
}

View File

@@ -1,14 +1,7 @@
import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import {
Button,
Field,
FieldError,
FieldLabel,
Input,
Skeleton,
} from "@edr/ui-common";
import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
@@ -19,17 +12,13 @@ import {
import {
AlertBox,
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step5CargoDetails({
form,
@@ -44,7 +33,6 @@ export function Step5CargoDetails({
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
@@ -61,9 +49,7 @@ export function Step5CargoDetails({
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter(
(g) => g.code !== "CONTAINER",
);
return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
}, [referenceData]);
const commodityOptions = useMemo(() => {
@@ -97,14 +83,14 @@ export function Step5CargoDetails({
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-border p-4">
<Skeleton className="h-4 w-24" />
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-1/3" />
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
</div>
</div>
);
@@ -117,28 +103,27 @@ export function Step5CargoDetails({
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", {
shouldDirty: true,
});
form.setValue("freightType", "", { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
@@ -153,45 +138,41 @@ export function Step5CargoDetails({
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
</div>
{/* Weight */}
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cargoWeight">
Total Cargo Weight(Tons)*
</FieldLabel>
<div className="relative">
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...field}
id="cargoWeight"
type="number"
aria-invalid={fieldState.invalid}
placeholder="0.00"
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
{...field}
id="cargoWeight"
type="number"
label="Total Cargo Weight (Tons) *"
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
min={0}
step={0.01}
/>
)}
/>
</div>
{/* Bulk freight type */}
{cargoType === "bulk" && (
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
@@ -199,7 +180,7 @@ export function Step5CargoDetails({
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-3 sm:grid-cols-2">
{freightTypeGroups.map((group) => {
const val = group.code.toLowerCase();
@@ -219,36 +200,30 @@ export function Step5CargoDetails({
);
})}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{freightType && commodityOptions.length > 0 && (
<div className="space-y-2">
<Controller
name="bulkCommoditytype"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
placeholder="Select type *"
>
{commodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
</div>
<Controller
name="bulkCommoditytype"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Cargo type *"
placeholder="Select type *"
data={commodityOptions}
/>
)}
/>
)}
</div>
)}
{/* Container list */}
{cargoType === "container" && (
<>
<div className="space-y-4">
@@ -256,18 +231,14 @@ export function Step5CargoDetails({
<StepLabel>Containers</StepLabel>
<Button
type="button"
variant="outline"
variant="default"
size="sm"
radius="md"
leftSection={<Plus size={14} />}
onClick={() =>
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "",
})
append({ type: "20ft", containerType: "", qty: "1", vgm: "" })
}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Container
</Button>
</div>
@@ -280,25 +251,31 @@ export function Step5CargoDetails({
return (
<div
key={field.id}
className="rounded-xl flex flex-col border border-border p-3 gap-2"
className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
>
<div className="flex items-center justify-between">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
Container {index + 1}
</Text>
{fields.length > 1 && (
<button
type="button"
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)}
className="text-xs text-destructive hover:underline"
aria-label="Remove container"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<Trash2 size={15} />
</ActionIcon>
)}
</div>
{/* Container size */}
<Controller
name={`containers.${index}.type`}
control={form.control}
render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
@@ -321,52 +298,47 @@ export function Step5CargoDetails({
onClick={() => typeField.onChange(ct.val)}
>
<div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-muted-foreground">
{ct.limit}
</p>
<p className="text-xs text-gray-500">{ct.limit}</p>
</OptionCard>
))}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
<OptionFieldError error={fieldState.error} />
</div>
)}
/>
{/* Qty + VGM + Type */}
<div className="grid gap-3 sm:grid-cols-3">
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Quantity *</FieldLabel>
<div>
<Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
-
</button>
<Input
<input
value={qtyField.value ?? 1}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onChange={(e) => qtyField.onChange(e.target.value)}
onBlur={qtyField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
className="text-center"
min="1"
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
@@ -375,13 +347,17 @@ export function Step5CargoDetails({
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
>
+
</button>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
{fieldState.error?.message && (
<Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)}
/>
@@ -389,20 +365,18 @@ export function Step5CargoDetails({
name={`containers.${index}.vgm`}
control={form.control}
render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Tons*</FieldLabel>
<Input
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
placeholder="e.g. 18.5"
min="0"
step="0.1"
/>
<FieldError errors={[fieldState.error]} />
</Field>
<TextInput
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
label="Tons *"
placeholder="e.g. 18.5"
error={fieldState.error?.message}
radius="md"
min={0}
step={0.1}
/>
)}
/>
@@ -415,13 +389,8 @@ export function Step5CargoDetails({
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
>
{containerTypeOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
data={containerTypeOptions}
/>
)}
/>
</div>
@@ -441,18 +410,13 @@ export function Step5CargoDetails({
if (result.hasOddUnit) {
return (
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which{" "}
<strong>may delay departure</strong> beyond the standard
lead time.
</p>
</div>
</div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the remaining
slot, which <strong>may delay departure</strong> beyond the
standard lead time.
</p>
</AlertBox>
);
}

View File

@@ -1,27 +1,15 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Check } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldError,
FieldLabel,
Textarea,
} from "@edr/ui-common";
import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormValues,
type RouteDirection,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
export function Step8Review({
form,
@@ -47,13 +35,17 @@ export function Step8Review({
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-sm font-medium">{value || "-"}</p>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-primary hover:underline"
className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
>
Edit
</button>
@@ -64,26 +56,60 @@ export function Step8Review({
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const cargoValue =
values.cargoType === "container"
? containerSummary
: values.freightType === "bulk"
? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}`
? `Bulk ${values.bulkCommoditytype}`
: values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
? `Break-Bulk`
: "";
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return (
<div className="space-y-6">
<StepHeader
@@ -91,153 +117,130 @@ export function Step8Review({
description="Confirm your contract request before sending it for EDR staff review."
/>
<div className="grid gap-4 md:grid-cols-2">
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract & Service
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={
values.customsClearingEnabled ? "Enabled" : "Not requested"
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Route"
value={`${values.originYard} -> ${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<ReviewCard title="Contract & Service">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CardContent>
</Card>
}
target={2}
/>
</ReviewCard>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container & Wagons
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Containers"
value={containerSummary || "-"}
target={4}
/>
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</CardContent>
</Card>
</div>
<ReviewCard title="First & Last Mile">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</ReviewCard>
<ReviewCard title="Documents">
<Row
label="Attached"
value={
docsAttached > 0
? `${docsAttached} of ${docsTotal} attached`
: "None — upload later from the booking page"
}
target={5}
/>
</ReviewCard>
</SimpleGrid>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="notes">Additional Notes</FieldLabel>
<Textarea
{...field}
id="notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
/>
</Field>
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
radius="md"
/>
)}
/>
@@ -245,31 +248,24 @@ export function Step8Review({
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<label className="flex cursor-pointer items-start gap-3">
<button
type="button"
role="checkbox"
aria-checked={field.value}
aria-invalid={fieldState.invalid}
onClick={() => field.onChange(!field.value)}
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${field.value ? "border-primary bg-primary" : "border-input"
}`}
>
{field.value && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</button>
<span className="text-sm text-muted-foreground">
<Checkbox
label={
<Text size="sm" c="dimmed">
I confirm the information is accurate and agree to EDR's{" "}
<span className="text-primary">
<Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions
</span>
</Text>
.
</span>
</label>
<FieldError errors={[fieldState.error, errors.termsAccepted]} />
</Field>
</Text>
}
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
error={
fieldState.error?.message ?? errors.termsAccepted?.message
}
color="edr-green"
radius="sm"
/>
)}
/>
</div>

View File

@@ -2,4 +2,5 @@ export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type";
export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details";
export { StepDocuments } from "./step-documents";
export { Step8Review } from "./step8-review";

View File

@@ -0,0 +1,158 @@
import {
colorsTuple,
createTheme,
type MantineColorsTuple,
} from "@mantine/core";
const edrGreen: MantineColorsTuple = [
"#ecfdf5",
"#d1fae5",
"#a7f3d0",
"#6ee7b7",
"#34d399",
"#0EA371",
"#0A8A5F",
"#0A6F4D",
"#065f46",
"#064e3b",
];
const neutral: MantineColorsTuple = [
"#F4F7FA",
"#eef2f7",
"#E6ECF2",
"#d0dae6",
"#b0bfce",
"#8fa0b2",
"#6B7C8E",
"#4b5a6a",
"#10202F",
"#0C1A2B",
];
export const mantineTheme = createTheme({
colors: {
"edr-green": edrGreen,
gray: neutral,
// Brand surface + text tokens (single-value semantic colors).
// Each generates --mantine-color-{name}-{0..9} CSS variables.
"edr-bg": colorsTuple("#F7FAFC"),
"edr-card": colorsTuple("#FFFFFF"),
"edr-border": colorsTuple("#E6ECF2"),
"edr-divider": colorsTuple("#EEF1F5"),
"edr-text": colorsTuple("#10202F"),
"edr-muted": colorsTuple("#6B7C8E"),
"edr-soft": colorsTuple("#ECF6F1"),
"edr-ink": colorsTuple("#0C1A2B"),
"edr-accent": colorsTuple("#F2A516"),
// Semantic status shades (from the design system).
"edr-amber-soft": colorsTuple("#FDF3E0"),
"edr-amber-text": colorsTuple("#9A5B00"),
"edr-blue": colorsTuple("#2E5B96"),
"edr-blue-soft": colorsTuple("#E9F0F8"),
"edr-blue-dot": colorsTuple("#3B6FB0"),
"edr-red": colorsTuple("#C0392B"),
"edr-red-soft": colorsTuple("#FBEAE7"),
"edr-slate": colorsTuple("#475569"),
"edr-slate-soft": colorsTuple("#F1F4F7"),
"edr-slate-soft2": colorsTuple("#EEF2F6"),
"edr-step": colorsTuple("#94A3B8"),
"edr-step-idle": colorsTuple("#D5DBE2"),
"edr-conn-idle": colorsTuple("#E6EAEF"),
},
primaryColor: "edr-green",
primaryShade: { light: 5, dark: 4 },
white: "#ffffff",
black: "#10202F",
fontFamily:
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
defaultRadius: "md",
radius: {
xs: "4px",
sm: "6px",
md: "8px",
lg: "12px",
xl: "18px",
},
spacing: {
xs: "8px",
sm: "12px",
md: "16px",
lg: "24px",
xl: "32px",
},
fontSizes: {
xs: "12px",
sm: "13px",
md: "14px",
lg: "16px",
xl: "18px",
},
lineHeights: {
xs: "1.4",
sm: "1.45",
md: "1.55",
lg: "1.55",
xl: "1.5",
},
headings: {
fontFamily: '"Inter", var(--mantine-font-family)',
fontWeight: "700",
sizes: {
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },
h2: { fontSize: "28px", lineHeight: "1.2", fontWeight: "700" },
h3: { fontSize: "22px", lineHeight: "1.3", fontWeight: "600" },
h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" },
h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" },
h6: { fontSize: "13px", lineHeight: "1.45", fontWeight: "600" },
},
},
shadows: {
xs: "0 1px 2px rgba(16, 24, 40, 0.04)",
sm: "0 1px 3px rgba(16, 24, 40, 0.06)",
md: "0 4px 12px rgba(16, 24, 40, 0.06)",
},
components: {
Card: {
defaultProps: {
radius: "xl",
withBorder: true,
shadow: "none",
padding: "lg",
},
styles: {
root: { borderColor: "#E6ECF2" },
},
},
Button: {
defaultProps: { radius: "md" },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: "xl", variant: "light" },
styles: { root: { fontWeight: 600, textTransform: "none" } },
},
Paper: {
defaultProps: { radius: "xl", shadow: "none", withBorder: true },
styles: { root: { borderColor: "#E6ECF2" } },
},
Table: {
defaultProps: { verticalSpacing: "sm", horizontalSpacing: "md" },
},
Title: {
styles: { root: { letterSpacing: "-0.01em" } },
},
},
});

View File

@@ -113,9 +113,6 @@ client.interceptors.response.use(
} catch (refreshError) {
processQueue(refreshError, undefined);
clearAuthCookies();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;

View File

@@ -1,6 +1,6 @@
import { Table } from "@mantine/core";
import { Table as TanstackTable } from "@tanstack/react-table";
import { TableCell, TableRow } from "../table";
import { Button } from "../button";
export function DataTableError({
@@ -15,8 +15,8 @@ export function DataTableError({
onRetry?: () => void;
}) {
return (
<TableRow>
<TableCell colSpan={table.getVisibleFlatColumns().length}>
<Table.Tr>
<Table.Td colSpan={table.getVisibleFlatColumns().length}>
<div className="flex items-center my-6 justify-center">
<div className="text-center">
<div className="my-4">
@@ -34,7 +34,7 @@ export function DataTableError({
)}
</div>
</div>
</TableCell>
</TableRow>
</Table.Td>
</Table.Tr>
);
}

View File

@@ -1,16 +1,16 @@
import { Table } from "@mantine/core";
import { Table as TanstackTable } from "@tanstack/react-table";
import { Skeleton } from "../skeleton";
import { TableCell, TableRow } from "../table";
export function DataTableSkeleton({ table }: { table: TanstackTable<any> }) {
return Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<Table.Tr key={i}>
{table.getAllColumns().map((column) => (
<TableCell key={column.id}>
<Table.Td key={column.id}>
<Skeleton className="h-8" />
</TableCell>
</Table.Td>
))}
</TableRow>
</Table.Tr>
));
}

View File

@@ -4,14 +4,7 @@ import {
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../table";
import { Table } from "@mantine/core";
import { DataTableProps } from "./types";
import { DataTableSkeleton } from "./skeleton";
import { DataTableError } from "./error";
@@ -56,12 +49,12 @@ export function DataTable<TData, TValue>({
<>
<div className={containerClassName}>
<Table>
<TableHeader>
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
<Table.Th
key={header.id}
className={
(header.column.columnDef.meta as Record<string, any>)
@@ -75,13 +68,13 @@ export function DataTable<TData, TValue>({
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
</Table.Th>
);
})}
</TableRow>
</Table.Tr>
))}
</TableHeader>
<TableBody>
</Table.Thead>
<Table.Tbody>
{status === "loading" && <DataTableSkeleton table={table} />}
{status === "error" && (
<DataTableError
@@ -94,7 +87,7 @@ export function DataTable<TData, TValue>({
{status === "success" &&
(table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
<Table.Tr
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={(event) => {
@@ -129,7 +122,7 @@ export function DataTable<TData, TValue>({
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
<Table.Td
key={cell.id}
className={
(cell.column.columnDef.meta as Record<string, any>)
@@ -140,21 +133,21 @@ export function DataTable<TData, TValue>({
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
</Table.Td>
))}
</TableRow>
</Table.Tr>
))
) : (
<TableRow>
<TableCell
<Table.Tr>
<Table.Td
colSpan={table.getVisibleFlatColumns().length}
className="h-24 text-center"
>
{emptyMessage ?? "No data"}
</TableCell>
</TableRow>
</Table.Td>
</Table.Tr>
))}
</TableBody>
</Table.Tbody>
</Table>
</div>

6
pnpm-lock.yaml generated
View File

@@ -301,6 +301,12 @@ importers:
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@mantine/core':
specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks':
specifier: ^9.3.0
version: 9.3.0(react@19.2.6)
'@tanstack/react-query':
specifier: ^5.59.0
version: 5.101.0(react@19.2.6)