mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style:wip dashboard ui revamp
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
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>
|
||||
</div>
|
||||
</Text>
|
||||
</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 (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{perms.length === 0 ? (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
) : (
|
||||
perms.map((p) => (
|
||||
<span
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{perms.map((p) => (
|
||||
<Badge
|
||||
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"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Shield size={11} />}
|
||||
>
|
||||
<Shield />
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</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()}
|
||||
<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)}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<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
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Dropdown Settings" },
|
||||
{ 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 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<Card p={0}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
p="md"
|
||||
className="border-b border-edr-border"
|
||||
>
|
||||
<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>
|
||||
<Text fw={600} c="edr-text">
|
||||
Registered Dropdowns
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Every dynamic dropdown the platform reads from.
|
||||
</CardDescription>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" size="sm">
|
||||
<Filter />
|
||||
<Button variant="default" size="sm" leftSection={<Filter size={16} />}>
|
||||
Filter
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Group>
|
||||
|
||||
<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={() => { }}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load dropdown settings.",
|
||||
description:
|
||||
error instanceof Error ? error.message : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: pageCount,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
containerClassName="border-0 shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
|
||||
reliably after a menu item is selected. */}
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,47 +186,47 @@ 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()}`
|
||||
}
|
||||
accent="teal"
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Tabs
|
||||
value={statusTab}
|
||||
@@ -297,9 +235,8 @@ export default function PaymentsPage() {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
variant="pills"
|
||||
color="green"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => {
|
||||
@@ -317,17 +254,7 @@ export default function PaymentsPage() {
|
||||
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
|
||||
}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
>
|
||||
{count}
|
||||
</MantineBadge>
|
||||
@@ -341,12 +268,7 @@ export default function PaymentsPage() {
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: "white", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Card padding="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
@@ -371,7 +293,6 @@ export default function PaymentsPage() {
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Select
|
||||
placeholder="All methods"
|
||||
@@ -382,7 +303,6 @@ export default function PaymentsPage() {
|
||||
setMethod(value);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -407,18 +327,11 @@ export default function PaymentsPage() {
|
||||
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>
|
||||
</Stack>
|
||||
</Container>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,13 +40,8 @@ export default function WarehouseDashboardPage() {
|
||||
const { data, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Warehouse dashboard' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
@@ -66,39 +56,20 @@ export default function WarehouseDashboardPage() {
|
||||
{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>
|
||||
@@ -109,7 +80,6 @@ export default function WarehouseDashboardPage() {
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,33 +80,24 @@ export default function WarehouseDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
{ label: 'Warehouses', href: '/dashboard/warehouses' },
|
||||
{ label: warehouse.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
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>
|
||||
<Text c="dimmed" size="sm">
|
||||
{warehouse.code}
|
||||
{warehouse.locationName ? ` · ${warehouse.locationName}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs.List>
|
||||
@@ -141,18 +117,23 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
{/* 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)}
|
||||
<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,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<StatCard
|
||||
label="Containers (cur / cap)"
|
||||
value={formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
@@ -309,7 +290,6 @@ export default function WarehouseDetailPage() {
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
{id && (
|
||||
<CreateYardModal
|
||||
@@ -327,6 +307,6 @@ export default function WarehouseDetailPage() {
|
||||
zone={editingZone}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user