mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
Merge pull request #142 from Tria-plc/freight/feat/ui-migration
UI migration Again
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/edr-logo.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/edr-freight-web/portal/public/assets/train-edr.jpg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/train-edr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
648
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal file
648
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal 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,
|
||||
}}
|
||||
>
|
||||
Ethio–Djibouti 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
export const PROGRESS_STAGES = [
|
||||
{
|
||||
label: "Request",
|
||||
icon: FileText,
|
||||
statuses: ["DRAFT", "CHANGES_REQUESTED"],
|
||||
},
|
||||
{
|
||||
label: "Submitted",
|
||||
icon: ClipboardCheck,
|
||||
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
icon: ShieldCheck,
|
||||
statuses: [
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"APPROVED",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "In Transit",
|
||||
icon: Train,
|
||||
statuses: [
|
||||
"PNR_GENERATED",
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"PENDING_CONSOLIDATION",
|
||||
"CONSOLIDATED",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Complete",
|
||||
icon: PackageCheck,
|
||||
statuses: ["COMPLETED", "DELIVERED"],
|
||||
},
|
||||
];
|
||||
|
||||
export const STATUS_MAP: Record<
|
||||
string,
|
||||
{ title: string; description: string; stage: number }
|
||||
> = {
|
||||
DRAFT: {
|
||||
title: "Draft — not submitted",
|
||||
description:
|
||||
"This booking is being prepared and hasn’t been submitted for review yet.",
|
||||
stage: 0,
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
title: "Changes requested",
|
||||
description: "Staff has requested changes. Please review and resubmit.",
|
||||
stage: 0,
|
||||
},
|
||||
SUBMITTED: {
|
||||
title: "Submitted for review",
|
||||
description: "Your booking has been submitted and is awaiting review.",
|
||||
stage: 1,
|
||||
},
|
||||
PENDING_APPROVAL: {
|
||||
title: "Pending approval",
|
||||
description: "Your booking is moving through the approval process.",
|
||||
stage: 1,
|
||||
},
|
||||
APPROVED_PENDING_SIGNATURE: {
|
||||
title: "Approved — awaiting signature",
|
||||
description: "Approved. Your contract will be ready to sign shortly.",
|
||||
stage: 2,
|
||||
},
|
||||
APPROVED: {
|
||||
title: "Approved",
|
||||
description: "Your booking has been fully approved.",
|
||||
stage: 2,
|
||||
},
|
||||
CONTRACT_READY: {
|
||||
title: "Contract ready to sign",
|
||||
description:
|
||||
"Your contract is ready. Review and apply your signature to proceed.",
|
||||
stage: 2,
|
||||
},
|
||||
SIGNED_CUSTOMER: {
|
||||
title: "Signed — awaiting staff",
|
||||
description:
|
||||
"Your signature has been submitted. Awaiting the final staff signature.",
|
||||
stage: 2,
|
||||
},
|
||||
FULLY_EXECUTED: {
|
||||
title: "Contract fully executed",
|
||||
description: "Signed by all parties. You can now proceed to payment.",
|
||||
stage: 2,
|
||||
},
|
||||
PNR_GENERATED: {
|
||||
title: "Payment reference generated",
|
||||
description:
|
||||
"A payment reference number has been generated for this booking.",
|
||||
stage: 3,
|
||||
},
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: {
|
||||
title: "Verifying payment",
|
||||
description: "Your payment is being verified.",
|
||||
stage: 3,
|
||||
},
|
||||
PAID: {
|
||||
title: "Payment confirmed",
|
||||
description: "Payment has been confirmed for this booking.",
|
||||
stage: 3,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
title: "Cargo moving",
|
||||
description: "Your shipment is currently moving through the rail network.",
|
||||
stage: 3,
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending consolidation",
|
||||
description: "Awaiting a consolidation partner shipment.",
|
||||
stage: 3,
|
||||
},
|
||||
CONSOLIDATED: {
|
||||
title: "Consolidated",
|
||||
description: "Cargo has been consolidated with a partner shipment.",
|
||||
stage: 3,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
},
|
||||
DELIVERED: {
|
||||
title: "Service complete",
|
||||
description: "Cargo delivered and service successfully terminated.",
|
||||
stage: 4,
|
||||
},
|
||||
REJECTED: {
|
||||
title: "Booking rejected",
|
||||
description: "This booking request has been rejected.",
|
||||
stage: -1,
|
||||
},
|
||||
CANCELLED: {
|
||||
title: "Booking cancelled",
|
||||
description: "This booking process has been terminated.",
|
||||
stage: -1,
|
||||
},
|
||||
};
|
||||
|
||||
export const REQUIRED_DOC_FIELDS = [
|
||||
{ key: "commercial_invoice", label: "Commercial Invoice" },
|
||||
{ key: "packing_list", label: "Packing List" },
|
||||
{ key: "certificate_of_origin", label: "Certificate of Origin" },
|
||||
{ key: "letter_of_credit", label: "Letter of Credit / LC" },
|
||||
];
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
158
apps/edr-freight-web/portal/src/theme/mantine.ts
Normal file
158
apps/edr-freight-web/portal/src/theme/mantine.ts
Normal 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" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user