style:wip dashboard ui revamp

This commit is contained in:
Nathnael
2026-06-20 10:20:38 +00:00
parent e4955c75de
commit 138ac5b47d
10 changed files with 647 additions and 669 deletions

View File

@@ -47,7 +47,6 @@ const ISLAND =
"flex size-9 shrink-0 items-center justify-center rounded-full border border-edr-border bg-white text-edr-text transition-colors hover:bg-[#F1F4F7]";
const FreightDashboardHeader = ({
pageMeta,
headerRight,
enableThemeToggle = false,
userName = "User",
@@ -86,7 +85,8 @@ const FreightDashboardHeader = ({
}}
>
<Group h="100%" px={20} justify="space-between" wrap="nowrap">
{/* Left: burger (mobile) + page title */}
{/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
@@ -95,27 +95,22 @@ const FreightDashboardHeader = ({
size="sm"
aria-label="Toggle sidebar"
/>
<Text fw={700} truncate className="text-edr-text!" fz={17} style={{ letterSpacing: "-0.02em", lineHeight: 1.2 }}>
{pageMeta.title}
</Text>
</Group>
{/* Right: search + actions + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Search pill */}
<Group
gap={8}
align="center"
visibleFrom="md"
visibleFrom="sm"
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
style={{ width: 260 }}
style={{ width: 280 }}
>
<Search size={15} className="text-edr-muted" strokeWidth={1.8} />
<Text size="sm" className="select-none text-edr-muted!">
Search bookings, trains
</Text>
</Group>
</Group>
{/* Right: actions + avatar */}
<Group gap={10} wrap="nowrap" align="center">
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />

View File

@@ -50,6 +50,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "View booking payment transactions",
},
},
{
prefix: "/dashboard/warehouse-dashboard",
meta: {
title: "Warehouse Dashboard",
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
},
},
{
prefix: "/dashboard/warehouses/",
meta: {
title: "Warehouse detail",
subtitle: "Yards, zones, and inventory for this warehouse",
},
},
{
prefix: "/dashboard/warehouses",
meta: {
title: "Warehouses",
subtitle: "Manage warehouses, yards and zones",
},
},
{
prefix: "/dashboard/operations/train-scheduling-v2/",
meta: {

View File

@@ -0,0 +1,94 @@
import { Card, Skeleton, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface KpiItem {
label: string;
value: ReactNode;
/** Optional leading icon rendered in a tinted chip. */
icon?: LucideIcon;
/** Secondary line under the label (e.g. a unit or comparison). */
hint?: string;
/**
* Mantine color name for the icon chip (e.g. "edr-green", "red", "yellow").
* Defaults to the brand green so a strip reads as uniform unless a page opts
* into semantic tints.
*/
color?: string;
}
export interface KpiStripProps {
items: KpiItem[];
/** Show skeletons in place of values while data loads. */
loading?: boolean;
}
/**
* A single bordered card divided into up to five KPI cells:
* `[ kpi | kpi | kpi ]`. Hairline dividers separate cells (vertical on wide
* screens, horizontal when they wrap). Surface, border and shadow all come from
* the theme — no per-cell backgrounds, gradients or custom shadows.
*/
export function KpiStrip({ items, loading = false }: KpiStripProps) {
// The spec caps a strip at five cells; extra items are dropped rather than
// silently overflowing into an unreadable row.
const cells = items.slice(0, 5);
return (
<Card withBorder shadow="sm" radius="lg" p={0} className="overflow-hidden">
<div className="flex flex-col sm:flex-row">
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
return (
<div
key={item.label}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
)}
>
{Icon ? (
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
background: `var(--mantine-color-${color}-1)`,
color: `var(--mantine-color-${color}-7)`,
}}
>
<Icon size={20} strokeWidth={2} />
</div>
) : null}
<div style={{ minWidth: 0 }}>
{loading ? (
<Skeleton height={26} width={72} radius="sm" my={2} />
) : (
<Text
fw={800}
fz={24}
lh={1.05}
c="edr-text"
style={{ letterSpacing: "-0.02em" }}
truncate
>
{item.value}
</Text>
)}
<Text size="xs" fw={600} c="edr-muted" truncate>
{item.label}
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
);
})}
</div>
</Card>
);
}
export default KpiStrip;

View File

@@ -0,0 +1,26 @@
import { Box, Stack, type MantineSpacing } from "@mantine/core";
import type { ReactNode } from "react";
export interface PageContainerProps {
children: ReactNode;
/** Drop the max-width cap for full-bleed pages (boards, very wide tables). */
fluid?: boolean;
/** Vertical gap between the page's stacked sections. */
gap?: MantineSpacing;
}
/**
* Standard page shell: one consistent inset + a vertical Stack so every
* dashboard page shares the same outer padding and inter-section rhythm.
* The surrounding AppShell.Main already paints the page background, so this
* never sets its own — pages stay on the shared `edr-bg` surface.
*/
export function PageContainer({ children, fluid = false, gap = "lg" }: PageContainerProps) {
return (
<Box px="lg" py="lg" mx="auto" w="100%" maw={fluid ? undefined : 1600}>
<Stack gap={gap}>{children}</Stack>
</Box>
);
}
export default PageContainer;

View File

@@ -0,0 +1,78 @@
import { ActionIcon, Group, Stack, Text, Title } from "@mantine/core";
import { ArrowLeft } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Breadcrumbs, { type BreadcrumbItem } from "@/components/ui/Breadcrumbs";
export interface PageHeaderProps {
title: string;
subtitle?: string;
/** Breadcrumb trail — pass only on nested pages (details, sub-resources). */
breadcrumbs?: BreadcrumbItem[];
/** Route to return to; renders a back arrow before the title. */
backTo?: string;
/** Inline content beside the title (e.g. status badges). */
meta?: ReactNode;
/** Right-aligned actions — the primary CTA lives here. */
action?: ReactNode;
}
/**
* Unified page header: optional breadcrumbs, a title (with optional back arrow
* and inline meta), a subtitle, and a right-aligned action slot. Keeps title /
* action placement and spacing identical across every dashboard page.
*/
export function PageHeader({
title,
subtitle,
breadcrumbs,
backTo,
meta,
action,
}: PageHeaderProps) {
const navigate = useNavigate();
return (
<Stack gap="sm">
{breadcrumbs?.length ? <Breadcrumbs items={breadcrumbs} /> : null}
<Group justify="space-between" align="flex-start" gap="md">
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
{backTo ? (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => navigate(backTo)}
aria-label="Go back"
>
<ArrowLeft size={18} />
</ActionIcon>
) : null}
<div style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="nowrap">
<Title order={2} className="truncate">
{title}
</Title>
{meta}
</Group>
{subtitle ? (
<Text c="dimmed" size="sm" mt={4}>
{subtitle}
</Text>
) : null}
</div>
</Group>
{action ? (
<Group gap="sm" wrap="nowrap">
{action}
</Group>
) : null}
</Group>
</Stack>
);
}
export default PageHeader;

View File

@@ -0,0 +1,6 @@
export { PageContainer } from "./PageContainer";
export type { PageContainerProps } from "./PageContainer";
export { PageHeader } from "./PageHeader";
export type { PageHeaderProps } from "./PageHeader";
export { KpiStrip } from "./KpiStrip";
export type { KpiItem, KpiStripProps } from "./KpiStrip";

View File

@@ -1,12 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
Pencil,
Plus,
@@ -16,8 +14,20 @@ import {
Sparkles,
Trash2,
} from "lucide-react";
import {
ActionIcon,
Badge,
Button,
Card,
Code,
Group,
Menu,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
@@ -30,18 +40,6 @@ import {
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
@@ -50,40 +48,17 @@ export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
null,
);
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
// `pointer-events: none` on <body> when a menu closes and a dialog opens
// in the same frame — wait two RAFs and then explicitly reset the body
// style so the dialog interior is interactive.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveSetting(setting);
setActiveDialog(dialog);
});
});
setActiveSetting(setting);
setActiveDialog(dialog);
};
const closeDialog = () => {
setActiveDialog(null);
// Keep activeSetting briefly so dialog content doesn't flash empty during
// the close animation; cleared on next open.
};
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
// dialog changes, schedule a body-style cleanup after the next paint.
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const closeDialog = () => setActiveDialog(null);
const { data, isLoading, isError, error } = useQuery(
api.dropdownSettings.list.queryOptions(),
@@ -138,41 +113,38 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" size={40} radius="xl">
<Settings size={18} />
</ThemeIcon>
<div style={{ minWidth: 0 }}>
<Text fw={500} c="edr-text" truncate>
{s.label}
</Text>
<Text size="xs" c="edr-muted" truncate>
{s.description ?? "No description"}
</p>
</Text>
</div>
</div>
</Group>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
cell: ({ row }) => <Code>{row.original.code}</Code>,
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Boxes size={16} className="text-edr-muted" />
<Text size="sm" fw={500} c="edr-text">
{row.original.children?.length ?? 0}
</Text>
</Group>
),
},
{
id: "behavior",
@@ -180,15 +152,21 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
<Group gap={4} wrap="wrap">
<Badge variant="light" color={s.multiple ? "edr-green" : "gray"}>
{s.multiple ? "Multi" : "Single"}
</Badge>
{s.meta?.searchable ? (
<Badge variant="light" color="edr-green">
Searchable
</Badge>
) : null}
{s.meta?.clearable ? (
<Badge variant="light" color="edr-green">
Clearable
</Badge>
) : null}
</Group>
);
},
},
@@ -196,24 +174,27 @@ export default function DropdownSettingsPage() {
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
const perms = row.original.meta?.permissions ?? [];
if (perms.length === 0) {
return (
<Text size="xs" c="dimmed">
</Text>
);
}
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
<Group gap={4} wrap="wrap">
{perms.map((p) => (
<Badge
key={p}
variant="light"
color="edr-green"
leftSection={<Shield size={11} />}
>
{p}
</Badge>
))}
</Group>
);
},
},
@@ -223,178 +204,136 @@ export default function DropdownSettingsPage() {
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Menu position="bottom-end" withinPortal shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="default" aria-label="Row actions">
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Eye size={15} />}>View</Menu.Item>
<Menu.Item
leftSection={<CheckCircle2 size={15} />}
onClick={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Pencil size={15} />}
onClick={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<Trash2 size={15} />}
color="red"
onClick={() => openDialogFor("delete", setting)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load dropdown settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
<PageContainer>
<PageHeader
title="Dropdown Settings"
subtitle="Manage every dynamic dropdown across the platform — labels, options, ordering, and permissions."
action={
<>
<TextInput
w={{ base: "100%", sm: 280 }}
leftSection={<Search size={16} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
placeholder="Search by code, label, description…"
/>
<Button
leftSection={<Plus size={16} />}
onClick={() => setCreateOpen(true)}
>
New Setting
</Button>
</CardHeader>
</>
}
/>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
<KpiStrip
loading={isLoading}
items={[
{ label: "Settings", value: dropdownSettings.length, icon: Settings },
{ label: "Total Options", value: totalOptions, icon: Boxes },
{ label: "Multi-select", value: multipleCount, icon: ListOrdered },
{ label: "Searchable", value: searchableCount, icon: Sparkles },
]}
/>
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
reliably after a menu item is selected. */}
<Card p={0}>
<Group
justify="space-between"
p="md"
className="border-b border-edr-border"
>
<div>
<Text fw={600} c="edr-text">
Registered Dropdowns
</Text>
<Text size="sm" c="dimmed">
Every dynamic dropdown the platform reads from.
</Text>
</div>
<Button variant="default" size="sm" leftSection={<Filter size={16} />}>
Filter
</Button>
</Group>
<DataTable
columns={columns}
data={paginatedData}
status={status}
error={
isError
? {
message: "Failed to load dropdown settings.",
description:
error instanceof Error ? error.message : undefined,
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none"
footer={DataTableFooter}
/>
</Card>
{/* Create — fully controlled, no shadcn trigger child. */}
<EditDropdownSettingDialog
mode="create"
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* Controlled row-action dialogs. */}
{activeSetting ? (
<>
<EditDropdownSettingDialog
@@ -420,50 +359,6 @@ export default function DropdownSettingsPage() {
/>
</>
) : null}
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}
function BehaviorChip({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
</PageContainer>
);
}

View File

@@ -1,18 +1,14 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Box,
Card,
Container,
Group,
Paper,
Badge as MantineBadge,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { Badge as MantineBadge } from "@mantine/core";
import {
CheckCircle2,
CircleDollarSign,
@@ -22,23 +18,18 @@ import {
Search,
X,
XCircle,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import "@/components/overview/overview.css";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
import type {
PaymentMethod,
PaymentRow,
} from "@/services/payments.service";
import { cn } from "@/lib/utils";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const STATUS_TABS = [
@@ -75,57 +66,6 @@ const STATUS_COLORS: Record<string, string> = {
refunded: "indigo",
};
function StatCard({
icon: Icon,
label,
value,
accent,
}: {
icon: LucideIcon;
label: string;
value: string | number;
accent: string;
}) {
return (
<Paper
p="md"
radius="lg"
style={{
flex: "1 1 180px",
minWidth: 160,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 11,
background: `var(--mantine-color-${accent}-1)`,
color: `var(--mantine-color-${accent}-7)`,
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Stack gap={1} style={{ minWidth: 0, flex: 1 }}>
<Text fw={800} size="24px" lh={1.05} style={{ color: "#0f172a" }} truncate>
{value}
</Text>
<Text size="xs" fw={600} c="dimmed" truncate>
{label}
</Text>
</Stack>
</Group>
</Paper>
);
}
function formatAmount(amount: number, currency: string): string {
return `${currency} ${Number(amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -172,8 +112,6 @@ export default function PaymentsPage() {
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
const tabCounts: Record<StatusTabKey, number | undefined> = {
all:
summary === undefined
@@ -248,177 +186,152 @@ export default function PaymentsPage() {
];
return (
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Payments" }]} />
<PageContainer>
<PageHeader
title="Payments"
subtitle="View and reconcile booking payment transactions."
/>
<Stack gap="lg" mt="md">
<Group grow gap="md" align="stretch" wrap="wrap">
<StatCard
icon={CircleDollarSign}
label="Total collected"
value={
summaryLoading
? "—"
: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`
<KpiStrip
loading={summaryLoading}
items={[
{
label: "Total collected",
value: `ETB ${Number(summary?.paidAmount ?? 0).toLocaleString()}`,
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Successful",
value: summary?.success ?? 0,
icon: CheckCircle2,
color: "green",
},
{
label: "Processing",
value: summary?.processing ?? 0,
icon: Loader2,
color: "yellow",
},
{
label: "Failed",
value: summary?.failed ?? 0,
icon: XCircle,
color: "red",
},
{
label: "Refunded",
value: summary?.refunded ?? 0,
icon: RotateCcw,
color: "indigo",
},
]}
/>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="edr-green"
keepMounted={false}
>
<Tabs.List>
{STATUS_TABS.map((t) => {
const isActive = statusTab === t.key;
const count = tabCounts[t.key];
const Icon = t.icon;
return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
<Card padding="md">
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
accent="teal"
style={{ flex: 1, minWidth: "200px" }}
/>
<StatCard
icon={CheckCircle2}
label="Successful"
value={val(summary?.success)}
accent="green"
/>
<StatCard
icon={Loader2}
label="Processing"
value={val(summary?.processing)}
accent="yellow"
/>
<StatCard
icon={XCircle}
label="Failed"
value={val(summary?.failed)}
accent="red"
/>
<StatCard
icon={RotateCcw}
label="Refunded"
value={val(summary?.refunded)}
accent="indigo"
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Tabs
value={statusTab}
onChange={(value) => {
setStatusTab((value as StatusTabKey) ?? "all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{STATUS_TABS.map((t) => {
const isActive = statusTab === t.key;
const count = tabCounts[t.key];
const Icon = t.icon;
return (
<Tabs.Tab
key={t.key}
value={t.key}
leftSection={<Icon size={17} strokeWidth={1.85} />}
rightSection={
count !== undefined ? (
<MantineBadge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</MantineBadge>
) : undefined
}
>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
<Card
p="md"
radius="lg"
withBorder
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
>
<Stack gap="md">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search order, booking, or transaction…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All methods"
clearable
data={METHOD_OPTIONS}
value={method}
onChange={(value) => {
setMethod(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
radius="lg"
style={{ minWidth: 180 }}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
)}
footer={DataTableFooter}
/>
</div>
</Stack>
</Card>
<div style={{ overflowX: "auto" }}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
footer={DataTableFooter}
/>
</div>
</Stack>
</Container>
</div>
</Card>
</PageContainer>
);
}

View File

@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
import {
ClipboardCheck,
PackageCheck,
@@ -11,33 +11,28 @@ import {
Layers,
} from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
import { PageContainer, PageHeader } from '@/components/page';
import { WarehouseDashboardCharts } from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
/** Brand palette: alternating orange + light green. */
const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' };
const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' };
interface Metric {
key: keyof WarehouseDashboard;
label: string;
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: typeof ORANGE;
}
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory' },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED' },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue' },
];
export default function WarehouseDashboardPage() {
@@ -45,60 +40,36 @@ export default function WarehouseDashboardPage() {
const { data, isLoading } = useWarehouseDashboard();
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="train"
secondaryVariant="warehouse"
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
/>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<>
{isLoading ? (
<Center py="xl">
<Loader />
</Center>
) : (
<>
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
radius="lg"
padding="lg"
onClick={() => navigate(metric.to)}
style={{
cursor: 'pointer',
background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`,
border: `1px solid ${metric.theme.border}`,
transition: 'box-shadow 150ms ease, transform 150ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`;
e.currentTarget.style.transform = 'translateY(-3px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '';
e.currentTarget.style.transform = '';
}}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
</Text>
<Text fw={800} size="32px" mt={8} style={{ color: metric.theme.text, lineHeight: 1.1 }}>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="filled"
size={46}
radius="md"
style={{ backgroundColor: metric.theme.solid, color: '#fff' }}
>
<ThemeIcon variant="light" color="edr-green" size={46} radius="md">
{metric.icon}
</ThemeIcon>
</Group>
@@ -107,9 +78,8 @@ export default function WarehouseDashboardPage() {
</SimpleGrid>
<WarehouseDashboardCharts data={data} />
</>
)}
</Stack>
</Container>
</>
)}
</PageContainer>
);
}

View File

@@ -8,17 +8,15 @@ import {
Container,
Group,
Loader,
SimpleGrid,
Stack,
Select,
Table,
Tabs,
Text,
Title,
} from '@mantine/core';
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
import {
CreateYardModal,
CreateZoneModal,
@@ -36,19 +34,6 @@ import {
} from '@/hooks/useWarehouses';
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text fw={700} size="lg" mt={4}>
{value}
</Text>
</Card>
);
}
export default function WarehouseDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -95,65 +80,61 @@ export default function WarehouseDetailPage() {
}
return (
<Container size="xxl" py="lg">
<Breadcrumbs
items={[
<PageContainer>
<PageHeader
breadcrumbs={[
{ label: 'Warehouses', href: '/dashboard/warehouses' },
{ label: warehouse.name },
]}
backTo="/dashboard/warehouses"
title={warehouse.name}
subtitle={`${warehouse.code}${
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
}`}
meta={
<Group gap="xs" wrap="nowrap">
<WarehouseTypeBadge type={warehouse.type} />
<WarehouseStatusBadge status={warehouse.status} />
</Group>
}
/>
<Stack gap="lg" mt="sm">
<Group justify="space-between" align="flex-start">
<Group gap="md" align="center">
<ActionIcon variant="subtle" color="gray" onClick={() => navigate('/dashboard/warehouses')}>
<ArrowLeft size={18} />
</ActionIcon>
<div>
<Group gap="sm">
<Title order={2}>{warehouse.name}</Title>
<WarehouseTypeBadge type={warehouse.type} />
<WarehouseStatusBadge status={warehouse.status} />
</Group>
<Text c="dimmed" size="sm">
{warehouse.code}
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
</Text>
</div>
</Group>
</Group>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
Yards
</Tabs.Tab>
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
Zones
</Tabs.Tab>
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
Inventory
</Tabs.Tab>
</Tabs.List>
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="yards" leftSection={<Boxes size={16} />}>
Yards
</Tabs.Tab>
<Tabs.Tab value="zones" leftSection={<LayoutGrid size={16} />}>
Zones
</Tabs.Tab>
<Tabs.Tab value="inventory" leftSection={<Package size={16} />}>
Inventory
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md">
<StatCard label="Type" value={humanizeEnum(warehouse.type)} />
<StatCard label="Yards" value={String(yards.length)} />
<StatCard
label="Weight (cur / cap)"
value={formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}
/>
<StatCard
label="Containers (cur / cap)"
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
/>
</SimpleGrid>
</Tabs.Panel>
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<KpiStrip
items={[
{ label: 'Type', value: humanizeEnum(warehouse.type) },
{ label: 'Yards', value: yards.length },
{
label: 'Weight (cur / cap)',
value: formatCapacity(warehouse.currentWeight, warehouse.capacityWeight),
},
{
label: 'Containers (cur / cap)',
value: formatCapacity(
warehouse.currentContainers,
warehouse.capacityContainers,
),
},
]}
/>
</Tabs.Panel>
{/* YARDS */}
<Tabs.Panel value="yards" pt="lg">
@@ -308,8 +289,7 @@ export default function WarehouseDetailPage() {
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
</Tabs>
{id && (
<CreateYardModal
@@ -327,6 +307,6 @@ export default function WarehouseDetailPage() {
zone={editingZone}
/>
)}
</Container>
</PageContainer>
);
}