mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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/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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
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 { 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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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':
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user