feat(ui): Integrate Mantine UI library, introduce AppLayout, and refactor dashboard

This commit is contained in:
ghost2023
2026-06-08 16:05:03 +03:00
parent 95fb544ec2
commit 41061462ff
7 changed files with 1110 additions and 342 deletions

View File

@@ -15,6 +15,8 @@
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui-common": "1.1.2",
"axios": "^1.7.7",

View File

@@ -3,10 +3,9 @@ import {
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,
@@ -39,12 +38,37 @@ import BillingPage from "./pages/billing/BillingPage";
import { useEffect } from "react";
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 /> },
{ section: "Overview", label: "Home", href: "/portal", icon: <Home size={18} /> },
{
section: "Operations",
label: "My Bookings",
href: "/bookings",
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 = () => {
@@ -65,7 +89,7 @@ const App = () => {
if (user && location.pathname === "/") navigate("/portal");
else if (!customer && !!isInProtectedRoutes) navigate("/onboarding");
}, [user, location, customer]);
}, [user, location, customer, customerQuery.isPending]);
if (isPending) {
return (
@@ -94,7 +118,7 @@ const App = () => {
</Route>
<Route
element={
<DashboardLayout
<AppLayout
title="EDR Freight"
sidebarItems={sidebarItems}
activeHref={location.pathname}
@@ -105,7 +129,7 @@ const App = () => {
onLogout={logout}
>
<Outlet />
</DashboardLayout>
</AppLayout>
}
>
<Route path="/portal" element={<MyPortalPage />} />

View 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;

View File

@@ -2,9 +2,12 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import "@edr/ui-common/styles.css";
import "../index.css";
import "@edr/ui-common/theme.css";
import { mantineTheme } from "./theme/mantine";
import App from "./App";
@@ -31,10 +34,12 @@ if (!rootElement) {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</StrictMode>,
);

View File

@@ -2,42 +2,43 @@ import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { format } from "date-fns";
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 {
ArrowRight,
Building2,
CheckCircle2,
ArrowUpRight,
CalendarDays,
Clock,
DollarSign,
Eye,
LoaderCircle,
Mail,
MapPin,
Package,
Phone,
Package2,
Plus,
Receipt,
Truck,
Train,
UploadCloud,
X,
} from "lucide-react";
import {
getCurrentCustomer,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { getMyInvoices, getMyShipments } from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.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 useAuth from "@/hooks/useAuth";
const ACTIVE_STATUSES = [
"DRAFT",
@@ -46,12 +47,36 @@ const ACTIVE_STATUSES = [
"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() {
const me = useMemo(() => getCurrentCustomer(), []);
const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false);
const bookingsQuery = useQuery(
api.bookings.list.queryOptions({
@@ -71,337 +96,469 @@ export default function MyPortalPage() {
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 recentBookings = myBookings.slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
const recentBookings = myBookings.slice(0, 6);
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 (
<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"
<Stack gap="xl" p="xl" maw={1240} mx="auto">
{/* ── Document setup notice ───────────────────────────────────── */}
{!documentsComplete && !dismissed && (
<Group
align="center"
gap="md"
style={{
borderRadius: "var(--mantine-radius-xl)",
background: "#fffbeb",
border: "1px solid #fde68a",
padding: "16px 20px",
}}
>
<Box
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
</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>
)}
View all
</Button>
</Group>
{/* 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>
<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>
{bookingsQuery.isPending ? (
<Stack gap="xs">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={48} radius="md" />
))}
</div>
)}
</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>
</Stack>
) : recentBookings.length === 0 ? (
<EmptyState message="You haven't booked any freight yet." />
) : (
<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}`)}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{recentBookings.map((booking) => {
const meta = BOOKING_STATUS_META[booking.status];
return (
<Table.Tr
key={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">
{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>
{/* 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>
<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>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="md"
radius="md"
color={meta?.color ?? "gray"}
variant="light"
>
<Receipt size={14} />
</ThemeIcon>
<Box>
<Text size="sm" fw={600}>
{formatCurrency(invoice.amount, invoice.currency)}
</Text>
<Group gap={4} c="dimmed">
<Clock size={11} />
<Text size="xs" c="dimmed">
Due {invoice.dueDate}
</Text>
</Group>
</Box>
</Group>
<Badge size="sm" color={meta?.color ?? "gray"}>
{invoice.status}
</Badge>
</Group>
);
})}
</Stack>
)}
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
);
}
function ProfileRow({
icon,
// ── Sub-components ────────────────────────────────────────────────────────────
function StatCard({
label,
value,
sub,
icon,
color,
ring,
}: {
icon: React.ReactNode;
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
ring?: { value: number; color: string };
}) {
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>
<Card>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lh={1.4}>
{label}
</Text>
<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 }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-muted text-foreground",
Delivered: "bg-primary/10 text-primary",
Delayed: "bg-destructive/10 text-destructive",
};
function ShipmentCard({
shipment,
}: {
shipment: ReturnType<typeof getMyShipments>[number];
}) {
const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus];
const color = meta?.color ?? "gray";
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
<Box
style={{
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-2)",
padding: "var(--mantine-spacing-sm)",
}}
>
{status}
</span>
<Group justify="space-between" mb={6} wrap="nowrap">
<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 }) {
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 EmptyState({ message }: { message: 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"}`}
<Box
py="xl"
style={{
border: "1px dashed var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
textAlign: "center",
}}
>
{status.replace(/_/g, " ")}
</span>
);
}
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>
<Text size="sm" c="dimmed">
{message}
</Text>
</Box>
);
}

View 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
View File

@@ -299,6 +299,12 @@ importers:
'@hookform/resolvers':
specifier: ^5.4.0
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':
specifier: ^5.59.0
version: 5.100.11(react@19.2.6)