mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard
This commit is contained in:
@@ -15,6 +15,8 @@
|
|||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tria-plc/iamui-common": "1.1.2",
|
"@tria-plc/iamui-common": "1.1.2",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import {
|
|||||||
useLocation,
|
useLocation,
|
||||||
Routes,
|
Routes,
|
||||||
Route,
|
Route,
|
||||||
Navigate,
|
|
||||||
Outlet,
|
Outlet,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||||
import {
|
import {
|
||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -39,12 +38,37 @@ import BillingPage from "./pages/billing/BillingPage";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "Home", href: "/portal", icon: <Home /> },
|
{ section: "Overview", label: "Home", href: "/portal", icon: <Home size={18} /> },
|
||||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
section: "Operations",
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
label: "My Bookings",
|
||||||
{ label: "Profile", href: "/profile", icon: <User /> },
|
href: "/bookings",
|
||||||
{ label: "Settings", href: "/settings", icon: <Settings /> },
|
icon: <CalendarCheck size={18} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Operations",
|
||||||
|
label: "Tracking",
|
||||||
|
href: "/tracking",
|
||||||
|
icon: <MapPin size={18} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Operations",
|
||||||
|
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 App = () => {
|
||||||
@@ -65,7 +89,7 @@ const App = () => {
|
|||||||
|
|
||||||
if (user && location.pathname === "/") navigate("/portal");
|
if (user && location.pathname === "/") navigate("/portal");
|
||||||
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
|
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
|
||||||
}, [user, location, customer]);
|
}, [user, location, customer, customerQuery.isPending]);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +118,7 @@ const App = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<DashboardLayout
|
<AppLayout
|
||||||
title="EDR Freight"
|
title="EDR Freight"
|
||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
@@ -105,7 +129,7 @@ const App = () => {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</DashboardLayout>
|
</AppLayout>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/portal" element={<MyPortalPage />} />
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
|
|||||||
427
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal file
427
apps/edr-freight-web/portal/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { Fragment, type ReactNode, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
AppShell,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Burger,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Menu,
|
||||||
|
NavLink,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronDown,
|
||||||
|
Languages,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Train,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface SidebarItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children?: SidebarItem[];
|
||||||
|
/** Optional group heading; a small label is rendered when it changes. */
|
||||||
|
section?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppLayoutProps {
|
||||||
|
title?: string;
|
||||||
|
sidebarItems: SidebarItem[];
|
||||||
|
activeHref?: string;
|
||||||
|
onNavigate?: (href: string) => void;
|
||||||
|
enableThemeToggle?: boolean;
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
onLogout?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
const THEME_KEY = "edr-theme";
|
||||||
|
|
||||||
|
function getStoredTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
|
if (stored === "dark" || stored === "light") return stored;
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared NavLink styling — green tint only when active, quiet neutral otherwise.
|
||||||
|
const navLinkStyles = {
|
||||||
|
root: {
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
label: { fontSize: "var(--mantine-font-size-sm)" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function AppLayout({
|
||||||
|
title = "EDR Freight",
|
||||||
|
sidebarItems,
|
||||||
|
activeHref = "",
|
||||||
|
onNavigate,
|
||||||
|
enableThemeToggle = false,
|
||||||
|
userName = "User",
|
||||||
|
userEmail,
|
||||||
|
onLogout,
|
||||||
|
children,
|
||||||
|
}: AppLayoutProps) {
|
||||||
|
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||||
|
const [theme, setTheme] = useState<Theme>(() =>
|
||||||
|
enableThemeToggle ? getStoredTheme() : "light",
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePath = activeHref.toLowerCase();
|
||||||
|
const navigate = (href: string) => onNavigate?.(href);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||||
|
setTheme(next);
|
||||||
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
|
localStorage.setItem(THEME_KEY, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = getInitials(userName);
|
||||||
|
const activePage = getActivePage(sidebarItems, activePath);
|
||||||
|
|
||||||
|
const isItemActive = (item: SidebarItem) =>
|
||||||
|
activePath === item.href.toLowerCase() ||
|
||||||
|
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
layout="alt"
|
||||||
|
navbar={{
|
||||||
|
width: 256,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !mobileOpen },
|
||||||
|
}}
|
||||||
|
header={{ height: 60 }}
|
||||||
|
padding={0}
|
||||||
|
>
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<AppShell.Header
|
||||||
|
withBorder
|
||||||
|
style={{ background: "var(--mantine-color-body)" }}
|
||||||
|
>
|
||||||
|
<Group h="100%" px="lg" justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Burger
|
||||||
|
opened={mobileOpen}
|
||||||
|
onClick={toggleMobile}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text fw={600} size="md">
|
||||||
|
{activePage ? activePage.label : title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Right: utility actions + user menu */}
|
||||||
|
<Group gap={4}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Change language"
|
||||||
|
>
|
||||||
|
<Languages size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Indicator color="red" size={7} offset={5} zIndex={10}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{enableThemeToggle && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
width={220}
|
||||||
|
position="bottom-end"
|
||||||
|
withinPortal
|
||||||
|
shadow="md"
|
||||||
|
offset={8}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<UnstyledButton
|
||||||
|
px={6}
|
||||||
|
py={4}
|
||||||
|
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Avatar color="edr-green" radius="xl" size={30}>
|
||||||
|
<Text fw={600} fz={11}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</Avatar>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
visibleFrom="sm"
|
||||||
|
maw={120}
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-gray-5)"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Box px="sm" py="xs">
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{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={<LogOut size={15} />}
|
||||||
|
color="red"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
{/* ── Sidebar ─────────────────────────────────────────────────────────── */}
|
||||||
|
<AppShell.Navbar
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
// Soft, near-neutral gray — warm enough to avoid the cold blue tint.
|
||||||
|
background: "#f7f7f6",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand */}
|
||||||
|
<Box
|
||||||
|
h={60}
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
background: "var(--mantine-color-edr-green-6)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Train size={18} color="white" strokeWidth={2.2} />
|
||||||
|
</Box>
|
||||||
|
<Text fw={650} size="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<ScrollArea flex={1} type="never" p="sm">
|
||||||
|
<Stack gap={2}>
|
||||||
|
{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"
|
||||||
|
fw={600}
|
||||||
|
c="dimmed"
|
||||||
|
tt="uppercase"
|
||||||
|
px="sm"
|
||||||
|
mt={i === 0 ? 0 : "md"}
|
||||||
|
mb={4}
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
{item.section}
|
||||||
|
</Text>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
return (
|
||||||
|
<Fragment key={item.href}>
|
||||||
|
{sectionLabel}
|
||||||
|
<NavLink
|
||||||
|
label={item.label}
|
||||||
|
leftSection={item.icon}
|
||||||
|
active={active || childActive}
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
defaultOpened={childActive}
|
||||||
|
styles={navLinkStyles}
|
||||||
|
>
|
||||||
|
{item.children!.map((child) => {
|
||||||
|
const cActive =
|
||||||
|
activePath === child.href.toLowerCase();
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={child.href}
|
||||||
|
label={child.label}
|
||||||
|
active={cActive}
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => navigate(child.href)}
|
||||||
|
styles={navLinkStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NavLink>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={item.href}>
|
||||||
|
{sectionLabel}
|
||||||
|
<NavLink
|
||||||
|
label={item.label}
|
||||||
|
leftSection={item.icon}
|
||||||
|
active={active}
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => navigate(item.href)}
|
||||||
|
styles={navLinkStyles}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Bottom user */}
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Avatar color="edr-green" radius="xl" size={30}>
|
||||||
|
<Text fw={600} fz={11}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</Avatar>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{userName}
|
||||||
|
</Text>
|
||||||
|
{userEmail && (
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{userEmail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
{/* ── Main ────────────────────────────────────────────────────────────── */}
|
||||||
|
<AppShell.Main style={{ background: "var(--mantine-color-body)" }}>
|
||||||
|
{children}
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
@@ -2,9 +2,12 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
import { mantineTheme } from "./theme/mantine";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
@@ -31,10 +34,12 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
</QueryClientProvider>
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,42 +2,43 @@ import { useMemo, useState } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Progress,
|
||||||
|
RingProgress,
|
||||||
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Building2,
|
ArrowUpRight,
|
||||||
CheckCircle2,
|
CalendarDays,
|
||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
|
||||||
Eye,
|
|
||||||
LoaderCircle,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package2,
|
||||||
Phone,
|
|
||||||
Plus,
|
Plus,
|
||||||
Receipt,
|
Receipt,
|
||||||
Truck,
|
Train,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer";
|
||||||
getCurrentCustomer,
|
|
||||||
getMyInvoices,
|
|
||||||
getMyShipments,
|
|
||||||
} from "@/lib/currentCustomer";
|
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
const ACTIVE_STATUSES = [
|
const ACTIVE_STATUSES = [
|
||||||
"DRAFT",
|
"DRAFT",
|
||||||
@@ -46,12 +47,36 @@ const ACTIVE_STATUSES = [
|
|||||||
"IN_TRANSIT",
|
"IN_TRANSIT",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BOOKING_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
DRAFT: { label: "Draft", color: "gray" },
|
||||||
|
SUBMITTED: { label: "Submitted", color: "blue" },
|
||||||
|
PENDING_APPROVAL: { label: "Pending", color: "orange" },
|
||||||
|
IN_TRANSIT: { label: "In Transit", color: "teal" },
|
||||||
|
COMPLETED: { label: "Completed", color: "edr-green" },
|
||||||
|
CANCELLED: { label: "Cancelled", color: "red" },
|
||||||
|
REJECTED: { label: "Rejected", color: "red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHIPMENT_STATUS_META: Record<ShipmentStatus, { color: string }> = {
|
||||||
|
"In Transit": { color: "teal" },
|
||||||
|
Delivered: { color: "edr-green" },
|
||||||
|
Delayed: { color: "red" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVOICE_STATUS_META: Record<InvoiceStatus, { color: string }> = {
|
||||||
|
Draft: { color: "gray" },
|
||||||
|
Sent: { color: "blue" },
|
||||||
|
Paid: { color: "edr-green" },
|
||||||
|
Overdue: { color: "red" },
|
||||||
|
Cancelled: { color: "gray" },
|
||||||
|
};
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
const me = useMemo(() => getCurrentCustomer(), []);
|
const { user, customer } = useAuth();
|
||||||
const myShipments = useMemo(() => getMyShipments(), []);
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
const bookingsQuery = useQuery(
|
const bookingsQuery = useQuery(
|
||||||
api.bookings.list.queryOptions({
|
api.bookings.list.queryOptions({
|
||||||
@@ -71,337 +96,469 @@ export default function MyPortalPage() {
|
|||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
);
|
);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
|
||||||
const totalOutstanding = outstandingInvoices
|
const totalOutstanding = outstandingInvoices
|
||||||
.filter((inv) => inv.currency === "USD")
|
.filter((inv) => inv.currency === "USD")
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
.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 recentBookings = myBookings.slice(0, 5);
|
const recentBookings = myBookings.slice(0, 6);
|
||||||
const recentInvoices = [...myInvoices].slice(0, 4);
|
const recentInvoices = myInvoices.slice(0, 4);
|
||||||
|
const completedInvoices = myInvoices.filter(
|
||||||
|
(inv) => inv.status === "Paid",
|
||||||
|
).length;
|
||||||
|
const invoiceTotal = myInvoices.length || 1;
|
||||||
|
const paidPct = Math.round((completedInvoices / invoiceTotal) * 100);
|
||||||
|
|
||||||
|
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
|
||||||
|
const documentsComplete = !!(customer as any)?.documentsComplete;
|
||||||
|
const hasOutstanding = outstandingInvoices.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background p-6">
|
<Stack gap="xl" p="xl" maw={1240} mx="auto">
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
{/* ── Document setup notice ───────────────────────────────────── */}
|
||||||
{/* Documents banner */}
|
{!documentsComplete && !dismissed && (
|
||||||
{!me.documentsComplete && !dismissed && (
|
<Group
|
||||||
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
align="center"
|
||||||
<UploadCloud className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
|
gap="md"
|
||||||
<div className="flex-1">
|
style={{
|
||||||
<p className="font-semibold">Upload your documents</p>
|
borderRadius: "var(--mantine-radius-xl)",
|
||||||
<p className="mt-0.5 text-amber-700">
|
background: "#fffbeb",
|
||||||
To enable all account features, please upload your Business
|
border: "1px solid #fde68a",
|
||||||
License, TIN Certificate, and National ID / Passport.
|
padding: "16px 20px",
|
||||||
</p>
|
}}
|
||||||
<Link
|
>
|
||||||
to="/settings?tab=documents"
|
<Box
|
||||||
className="mt-2 inline-flex items-center gap-1 font-medium text-amber-900 underline underline-offset-2 transition hover:text-amber-700"
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 11,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "#f59e0b",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UploadCloud size={19} color="white" strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text fw={700} size="lg" c="#78350f" lh={1.3}>
|
||||||
|
Finish setting up your account
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="#92400e" mt={2} lh={1.5}>
|
||||||
|
Upload your <strong>Business License</strong>,{" "}
|
||||||
|
<strong>TIN Certificate</strong>, and{" "}
|
||||||
|
<strong>National ID / Passport</strong> to unlock all features.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/settings?tab=documents"
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
background: "#f59e0b",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
rightSection={<ArrowRight size={16} />}
|
||||||
|
>
|
||||||
|
Upload docs
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Welcome (branded band) ──────────────────────────────────── */}
|
||||||
|
<Box
|
||||||
|
p="xl"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: "var(--mantine-radius-lg)",
|
||||||
|
border: "1px solid var(--mantine-color-edr-green-1)",
|
||||||
|
background:
|
||||||
|
"linear-gradient(120deg, var(--mantine-color-edr-green-0) 0%, var(--mantine-color-body) 60%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* faint rail-line motif */}
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
left: "auto",
|
||||||
|
width: 240,
|
||||||
|
opacity: 0.5,
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(90deg, var(--mantine-color-edr-green-2) 0, var(--mantine-color-edr-green-2) 2px, transparent 2px, transparent 16px)",
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(90deg, transparent 0%, black 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(90deg, transparent 0%, black 100%)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-end"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="md"
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" c="edr-green.7" fw={600}>
|
||||||
|
Welcome back
|
||||||
|
</Text>
|
||||||
|
<Title order={1} mt={4}>
|
||||||
|
{displayName}
|
||||||
|
</Title>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/bookings/new"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
>
|
||||||
|
New Booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Stats Row ───────────────────────────────────────────────── */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
|
||||||
|
<StatCard
|
||||||
|
label="Active Bookings"
|
||||||
|
value={myBookings.length.toString()}
|
||||||
|
icon={<Package2 size={18} />}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="In Transit"
|
||||||
|
value={activeShipments.length.toString()}
|
||||||
|
icon={<Train size={18} />}
|
||||||
|
color="teal"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Outstanding"
|
||||||
|
value={hasOutstanding ? formatCurrency(totalOutstanding, "USD") : "—"}
|
||||||
|
sub={
|
||||||
|
hasOutstanding ? `${outstandingInvoices.length} unpaid` : "All clear"
|
||||||
|
}
|
||||||
|
icon={<Receipt size={18} />}
|
||||||
|
color={hasOutstanding ? "red" : "edr-green"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Invoices Paid"
|
||||||
|
value={`${completedInvoices} / ${invoiceTotal}`}
|
||||||
|
icon={<CalendarDays size={18} />}
|
||||||
|
color="edr-green"
|
||||||
|
ring={{ value: paidPct, color: "edr-green" }}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* ── Main Grid: bookings + side panel ────────────────────────── */}
|
||||||
|
<Grid gutter="md">
|
||||||
|
{/* Recent Bookings (left, wider) */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
|
<Card h="100%">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Box>
|
||||||
|
<Title order={4}>Recent Bookings</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Your latest freight requests
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/bookings"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
rightSection={<ArrowUpRight size={14} />}
|
||||||
>
|
>
|
||||||
Upload now
|
View all
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Group>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Welcome banner */}
|
{bookingsQuery.isPending ? (
|
||||||
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
<Stack gap="xs">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<div className="flex items-center gap-4">
|
<Skeleton key={i} height={48} radius="md" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
) : recentBookings.length === 0 ? (
|
||||||
</CardContent>
|
<EmptyState message="You haven't booked any freight yet." />
|
||||||
</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">
|
<Box style={{ overflowX: "auto" }}>
|
||||||
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
<thead className="text-xs text-muted-foreground">
|
<Table.Thead>
|
||||||
<tr>
|
<Table.Tr>
|
||||||
<th className="px-6 py-2 font-medium">Reference</th>
|
<Table.Th>Reference</Table.Th>
|
||||||
<th className="py-2 font-medium">Route</th>
|
<Table.Th>Route</Table.Th>
|
||||||
<th className="py-2 font-medium">Cargo</th>
|
<Table.Th>Date</Table.Th>
|
||||||
<th className="py-2 font-medium">Date</th>
|
<Table.Th>Status</Table.Th>
|
||||||
<th className="py-2 font-medium">Status</th>
|
</Table.Tr>
|
||||||
</tr>
|
</Table.Thead>
|
||||||
</thead>
|
<Table.Tbody>
|
||||||
<tbody>
|
{recentBookings.map((booking) => {
|
||||||
{recentBookings.map((booking) => (
|
const meta = BOOKING_STATUS_META[booking.status];
|
||||||
<tr
|
return (
|
||||||
key={booking.id}
|
<Table.Tr
|
||||||
className="border-t border-border transition hover:bg-primary/5 cursor-pointer"
|
key={booking.id}
|
||||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{booking.originYard?.label ??
|
||||||
|
booking.originYard?.code ??
|
||||||
|
"—"}
|
||||||
|
{" → "}
|
||||||
|
{booking.destinationYard?.label ??
|
||||||
|
booking.destinationYard?.code ??
|
||||||
|
"—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{format(
|
||||||
|
new Date(booking.createdAt),
|
||||||
|
"MMM d, yyyy",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={meta?.color ?? "gray"}>
|
||||||
|
{meta?.label ?? booking.status.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* Right side panel */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||||
|
<Stack gap="md" h="100%">
|
||||||
|
{/* Active Shipments */}
|
||||||
|
<Card style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Box>
|
||||||
|
<Title order={4}>Shipments</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
In-flight cargo
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Anchor component={Link} to="/tracking" size="sm" fw={500}>
|
||||||
|
<Group gap={4}>
|
||||||
|
All <ArrowRight size={13} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{activeShipments.length === 0 ? (
|
||||||
|
<EmptyState message="No active shipments." />
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{activeShipments.slice(0, 3).map((shipment) => (
|
||||||
|
<ShipmentCard key={shipment.id} shipment={shipment} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoice Summary */}
|
||||||
|
<Card>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Box>
|
||||||
|
<Title order={4}>Invoices</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{outstandingInvoices.length} outstanding
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Anchor component={Link} to="/billing" size="sm" fw={500}>
|
||||||
|
<Group gap={4}>
|
||||||
|
All <ArrowRight size={13} />
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{recentInvoices.length === 0 ? (
|
||||||
|
<EmptyState message="No invoices yet." />
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{recentInvoices.map((invoice) => {
|
||||||
|
const meta = INVOICE_STATUS_META[invoice.status];
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={invoice.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="px-6 py-3 font-medium text-foreground">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{booking.reference}
|
<ThemeIcon
|
||||||
</td>
|
size="md"
|
||||||
<td className="py-3 text-muted-foreground">
|
radius="md"
|
||||||
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} → {booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"}
|
color={meta?.color ?? "gray"}
|
||||||
</td>
|
variant="light"
|
||||||
<td className="py-3 text-muted-foreground">
|
>
|
||||||
{booking.freightType === "CONTAINER" ? "Container" : booking.freightType}
|
<Receipt size={14} />
|
||||||
</td>
|
</ThemeIcon>
|
||||||
<td className="py-3 text-muted-foreground">
|
<Box>
|
||||||
{format(new Date(booking.createdAt), "MMM d, yyyy HH:mm")}
|
<Text size="sm" fw={600}>
|
||||||
</td>
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
<td className="py-3">
|
</Text>
|
||||||
<BookingBadge status={booking.status} />
|
<Group gap={4} c="dimmed">
|
||||||
</td>
|
<Clock size={11} />
|
||||||
</tr>
|
<Text size="xs" c="dimmed">
|
||||||
))}
|
Due {invoice.dueDate}
|
||||||
</tbody>
|
</Text>
|
||||||
</table>
|
</Group>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
</Group>
|
||||||
</CardContent>
|
<Badge size="sm" color={meta?.color ?? "gray"}>
|
||||||
</Card>
|
{invoice.status}
|
||||||
|
</Badge>
|
||||||
{/* Invoices */}
|
</Group>
|
||||||
<Card>
|
);
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
})}
|
||||||
<div>
|
</Stack>
|
||||||
<CardTitle>Recent Invoices</CardTitle>
|
)}
|
||||||
<CardDescription>
|
</Card>
|
||||||
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
|
</Stack>
|
||||||
total
|
</Grid.Col>
|
||||||
</CardDescription>
|
</Grid>
|
||||||
</div>
|
</Stack>
|
||||||
<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>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileRow({
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
icon,
|
|
||||||
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
sub,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
ring,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
ring?: { value: number; color: string };
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<Card>
|
||||||
<div className="mt-0.5 text-primary">{icon}</div>
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<div className="flex-1">
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lh={1.4}>
|
||||||
<p className="mt-0.5 text-sm text-foreground">{value}</p>
|
{label}
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
<Text fz={28} fw={650} lh={1.2} mt={6}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{sub && (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{sub}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{ring ? (
|
||||||
|
<RingProgress
|
||||||
|
size={56}
|
||||||
|
thickness={5}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: ring.value, color: ring.color }]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fz={11} fw={600}>
|
||||||
|
{ring.value}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThemeIcon size={40} radius="md" color={color} variant="light">
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
function ShipmentCard({
|
||||||
const styles: Record<ShipmentStatus, string> = {
|
shipment,
|
||||||
"In Transit": "bg-muted text-foreground",
|
}: {
|
||||||
Delivered: "bg-primary/10 text-primary",
|
shipment: ReturnType<typeof getMyShipments>[number];
|
||||||
Delayed: "bg-destructive/10 text-destructive",
|
}) {
|
||||||
};
|
const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus];
|
||||||
|
const color = meta?.color ?? "gray";
|
||||||
return (
|
return (
|
||||||
<span
|
<Box
|
||||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
style={{
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
padding: "var(--mantine-spacing-sm)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status}
|
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||||
</span>
|
<Text fw={600} size="sm" truncate>
|
||||||
|
{shipment.reference}
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" color={color}>
|
||||||
|
{shipment.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
{shipment.originStation} → {shipment.destinationStation}
|
||||||
|
</Text>
|
||||||
|
<Progress value={shipment.progress} size={4} color={color} mb={8} />
|
||||||
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||||
|
<Group gap={4} c="dimmed" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<MapPin size={11} style={{ flexShrink: 0 }} />
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{shipment.currentLocation}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
|
ETA {shipment.eta}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingBadge({ status }: { status: string }) {
|
function EmptyState({ message }: { message: 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",
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span
|
<Box
|
||||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status] || "bg-muted text-muted-foreground"}`}
|
py="xl"
|
||||||
|
style={{
|
||||||
|
border: "1px dashed var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{status.replace(/_/g, " ")}
|
<Text size="sm" c="dimmed">
|
||||||
</span>
|
{message}
|
||||||
);
|
</Text>
|
||||||
}
|
</Box>
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
147
apps/edr-freight-web/portal/src/theme/mantine.ts
Normal file
147
apps/edr-freight-web/portal/src/theme/mantine.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||||
|
|
||||||
|
// Brand accent — used sparingly: primary actions, active nav, key highlights.
|
||||||
|
const edrGreen: MantineColorsTuple = [
|
||||||
|
"#ecfdf5",
|
||||||
|
"#d1fae5",
|
||||||
|
"#a7f3d0",
|
||||||
|
"#6ee7b7",
|
||||||
|
"#34d399",
|
||||||
|
"#10b981",
|
||||||
|
"#059669",
|
||||||
|
"#047857",
|
||||||
|
"#065f46",
|
||||||
|
"#064e3b",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Neutral gray ramp tuned for clean, low-contrast surfaces (Stripe/Notion feel).
|
||||||
|
const neutral: MantineColorsTuple = [
|
||||||
|
"#f8fafc",
|
||||||
|
"#f1f5f9",
|
||||||
|
"#e8edf2",
|
||||||
|
"#dbe2ea",
|
||||||
|
"#c2cbd6",
|
||||||
|
"#9aa6b4",
|
||||||
|
"#6b7785",
|
||||||
|
"#4b5563",
|
||||||
|
"#2f3742",
|
||||||
|
"#1c2129",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mantineTheme = createTheme({
|
||||||
|
colors: {
|
||||||
|
"edr-green": edrGreen,
|
||||||
|
gray: neutral,
|
||||||
|
},
|
||||||
|
primaryColor: "edr-green",
|
||||||
|
primaryShade: { light: 6, dark: 5 },
|
||||||
|
|
||||||
|
white: "#ffffff",
|
||||||
|
black: "#1c2129",
|
||||||
|
|
||||||
|
fontFamily:
|
||||||
|
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
|
||||||
|
defaultRadius: "md",
|
||||||
|
|
||||||
|
radius: {
|
||||||
|
xs: "4px",
|
||||||
|
sm: "6px",
|
||||||
|
md: "8px",
|
||||||
|
lg: "12px",
|
||||||
|
xl: "16px",
|
||||||
|
},
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hierarchy comes from a strong, exponential size scale (~1.25 modular ratio),
|
||||||
|
// not from heavy weights. Big steps at the top, calm weights throughout.
|
||||||
|
// "Plus Jakarta Sans" gives headings a distinctive geometric character while
|
||||||
|
// body text stays on the neutral system stack.
|
||||||
|
headings: {
|
||||||
|
fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)',
|
||||||
|
fontWeight: "600",
|
||||||
|
sizes: {
|
||||||
|
h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "700" },
|
||||||
|
h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "650" },
|
||||||
|
h3: { fontSize: "23px", 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), 0 1px 2px rgba(16, 24, 40, 0.04)",
|
||||||
|
md: "0 4px 12px rgba(16, 24, 40, 0.06)",
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
defaultProps: {
|
||||||
|
radius: "lg",
|
||||||
|
withBorder: true,
|
||||||
|
shadow: "none",
|
||||||
|
padding: "lg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
defaultProps: {
|
||||||
|
radius: "md",
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
root: { fontWeight: 550 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Badge: {
|
||||||
|
defaultProps: {
|
||||||
|
radius: "sm",
|
||||||
|
variant: "light",
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
root: { fontWeight: 550, textTransform: "none" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Paper: {
|
||||||
|
defaultProps: {
|
||||||
|
radius: "lg",
|
||||||
|
shadow: "none",
|
||||||
|
withBorder: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
defaultProps: {
|
||||||
|
verticalSpacing: "sm",
|
||||||
|
horizontalSpacing: "md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Title: {
|
||||||
|
styles: {
|
||||||
|
root: { letterSpacing: "-0.01em" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -299,6 +299,12 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.4.0(react-hook-form@7.76.0(react@19.2.6))
|
version: 5.4.0(react-hook-form@7.76.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.29)(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':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.59.0
|
specifier: ^5.59.0
|
||||||
version: 5.100.11(react@19.2.6)
|
version: 5.100.11(react@19.2.6)
|
||||||
|
|||||||
Reference in New Issue
Block a user