fix conflict

This commit is contained in:
yaschalew
2026-06-22 14:23:43 +03:00
145 changed files with 8199 additions and 9399 deletions

View File

@@ -68,7 +68,7 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
@@ -144,11 +144,11 @@ function StepRow({
const canApprove = canActOnApprovalStep(user, step, steps);
const statusColor =
step.status === "APPROVED"
? "green"
? "edr-green"
: step.status === "REJECTED"
? "red"
: isNext
? "green"
? "edr-green"
: "gray";
return (
@@ -200,7 +200,7 @@ function StepRow({
{canApprove && (
<Button
size="compact-sm"
color="green"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={() => onApprove(step)}

View File

@@ -86,7 +86,7 @@ export function BookingActionsMenu({
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "green" : "gray"}
color={destructive ? "red" : action.primary ? "edr-green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
@@ -113,7 +113,7 @@ export function BookingActionsMenu({
{variant === "table" && primary && (
<Button
size="compact-sm"
color="green"
color="edr-green"
visibleFrom="lg"
leftSection={<primary.icon size={14} />}
disabled={mutations.isPending}

View File

@@ -49,7 +49,7 @@ export function BookingConfirmDialog({
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "green";
const accent = isDestructive ? "red" : "edr-green";
return (
<Modal

View File

@@ -20,7 +20,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<Text
size="xl"
fw={700}
c="green.9"
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>

View File

@@ -62,7 +62,7 @@ export function BookingRequestsHeader({
return (
<Stack gap="lg">
<Group justify="flex-end" gap="sm">
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
<Button color="edr-green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
Create booking
</Button>
<Button

View File

@@ -8,7 +8,7 @@ const statusColorMap: Record<string, string> = {
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
APPROVED: "edr-green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
@@ -16,7 +16,7 @@ const statusColorMap: Record<string, string> = {
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
SELECTED_FOR_BATCH: "orange",
EXPIRED: "red",
PAID: "green",
PAID: "edr-green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",

View File

@@ -1,3 +1,4 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
CheckCircle,
ClipboardCheck,
@@ -8,13 +9,12 @@ import {
Wallet,
XCircle,
} from "lucide-react";
import { Badge, Tabs } from "@mantine/core";
import "@/components/overview/overview.css";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import "@/components/overview/overview.css";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />,
@@ -43,12 +43,13 @@ export function BookingStatusTabs({
value={active}
onChange={(value) => onChange((value as BookingStatusTabKey) ?? "all")}
variant="pills"
color="green"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{BOOKING_LIST_TABS.map((tab) => {
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
@@ -56,13 +57,14 @@ export function BookingStatusTabs({
key={tab.key}
value={tab.key}
leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
color={isActive ? "edr-green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
@@ -77,8 +79,9 @@ export function BookingStatusTabs({
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}

View File

@@ -83,7 +83,7 @@ export function BookingWorkflowStepper({
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -190,7 +190,7 @@ export function OperationsBookingQueue({
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="green"
color="edr-green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();

View File

@@ -19,14 +19,14 @@ export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCar
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="green"
accent="edr-green"
extra={
<Badge color="green" variant="light" radius="sm">
<Badge color="edr-green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
{steps.map((step) => (
<Timeline.Item
key={step.id}

View File

@@ -28,7 +28,7 @@ export function BookingDetailToolbar({
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
Export
</Button>
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
<Button color="edr-green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
Take Action
</Button>
</Group>

View File

@@ -68,7 +68,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>

View File

@@ -29,7 +29,7 @@ export function BookingPaymentCard({
</Text>
</Group>
<Badge
color={paymentStatus === "PAID" ? "green" : "yellow"}
color={paymentStatus === "PAID" ? "edr-green" : "yellow"}
variant="light"
radius="sm"
mt="xs"

View File

@@ -49,13 +49,7 @@ export function BookingRequestHero({
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
}}
style={{ position: "relative", overflow: "hidden" }}
>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
@@ -70,7 +64,7 @@ export function BookingRequestHero({
</Button>
<Button
variant="light"
color="green"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
@@ -87,7 +81,7 @@ export function BookingRequestHero({
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
@@ -130,7 +124,7 @@ export function BookingRequestHero({
minimumFractionDigits: 2,
})}`}
hint={booking.paymentStatus}
accent="green"
accent="edr-green"
/>
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
<HeroTile
@@ -177,7 +171,7 @@ function HeroTile({
label,
value,
hint,
accent = "green",
accent = "edr-green",
}: {
icon: LucideIcon;
label: string;
@@ -204,7 +198,7 @@ function HeroTile({
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
{label}
</Text>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap" }}>
{value}
</Text>
{hint ? (

View File

@@ -30,7 +30,7 @@ export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Group justify="space-between">
<Badge color="green" variant="light" size="sm" radius="sm">
<Badge color="edr-green" variant="light" size="sm" radius="sm">
{note.type}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -9,38 +9,29 @@ export interface SectionCardProps {
title: string;
/** Optional one-line context shown under the title. */
subtitle?: string;
/** Mantine palette key used to tint the icon chip + top accent (default green). */
/** Mantine palette key used to tint the icon chip (default brand green). */
accent?: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent card with a colored icon chip + accent stripe header used by every detail section. */
/** Consistent card with a colored icon chip header used by every detail section. */
export function SectionCard({
icon: Icon,
title,
subtitle,
accent = "green",
accent = "edr-green",
extra,
children,
}: SectionCardProps) {
return (
<Paper radius="md" withBorder style={{ ...detailStyles.card, overflow: "hidden" }}>
<Box
style={{
height: 3,
background: `linear-gradient(90deg, var(--mantine-color-${accent}-5) 0%, var(--mantine-color-${accent}-7) 100%)`,
}}
/>
<Group
justify="space-between"
px="xl"
py="md"
wrap="nowrap"
style={{
...detailStyles.cardHeader,
background: `linear-gradient(180deg, var(--mantine-color-${accent}-0) 0%, white 100%)`,
}}
style={detailStyles.cardHeader}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box

View File

@@ -55,7 +55,7 @@ export const detailStyles = {
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
return "edr-green";
case "PENDING":
return "yellow";
case "REJECTED":

View File

@@ -107,11 +107,11 @@ const FleetCardGrid = ({
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-green-0), var(--mantine-color-green-1))",
color: "var(--mantine-color-green-8)",
width: 40,
height: 40,
borderRadius: 10,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
display: "flex",
alignItems: "center",
justifyContent: "center",

View File

@@ -276,7 +276,7 @@ const FleetFormDialog = ({
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
<Button color="edr-green" loading={isSubmitting} onClick={handleSubmit}>
Save
</Button>
</Group>

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
import type { ReactNode } from "react";
import type { FleetViewMode } from "./useFleetViewMode";
@@ -67,7 +67,6 @@ const FleetToolbar = ({
onChange={(value) => onViewModeChange(value as FleetViewMode)}
size="sm"
radius="lg"
color="green"
data={[
{
value: "table",
@@ -94,7 +93,7 @@ const FleetToolbar = ({
/>
{onAdd ? (
<Button
color="green"
color="edr-green"
radius="lg"
size="sm"
fw={600}

View File

@@ -1,126 +0,0 @@
/* ============================================================
EDR Freight — Header styles
============================================================ */
.fdh-root {
display: flex;
height: 80px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 22px;
}
/* eyebrow above the page title */
.fdh-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #1B9E7A;
margin-bottom: 3px;
}
.fdh-eyebrow-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #2DBF95;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
}
/* action icon buttons */
.fdh-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: #f7f9fb;
border: 1px solid #eef1f4;
color: #475569;
cursor: pointer;
transition: all 160ms ease;
}
.fdh-icon-btn:hover {
background: #ffffff;
border-color: rgba(27, 158, 122, 0.28);
color: #1B9E7A;
box-shadow: 0 4px 12px -4px rgba(27, 158, 122, 0.28);
transform: translateY(-1px);
}
.fdh-icon-btn:active {
transform: translateY(0);
}
/* notification badge */
.fdh-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 1;
border: 2px solid #ffffff;
box-shadow: 0 2px 6px -1px rgba(239, 68, 68, 0.45);
}
.fdh-divider {
width: 1px;
height: 30px;
background: #e9eef3;
margin: 0 2px;
}
/* user button */
.fdh-user {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 12px 5px 5px;
border-radius: 13px;
cursor: pointer;
border: 1px solid transparent;
transition: all 160ms ease;
}
.fdh-user:hover {
background: #f7f9fb;
border-color: #eef1f4;
}
.fdh-avatar-ring {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 2px;
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
box-shadow: 0 4px 10px -3px rgba(27, 158, 122, 0.4);
}
.fdh-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, #1B9E7A 0%, #15805F 100%);
color: #ffffff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.3px;
border: 2px solid #ffffff;
}

View File

@@ -1,20 +1,31 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import {
AppShell,
Avatar,
Box,
Burger,
Divider,
Group,
Indicator,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Bell,
ChevronDown,
FileSignature,
Languages,
LogOut,
MessageSquare,
Moon,
Search,
Sun,
User,
} from "lucide-react";
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { PageMeta } from "./types";
import "./FreightDashboardHeader.css";
export interface FreightDashboardHeaderProps {
pageMeta: PageMeta;
@@ -26,10 +37,16 @@ export interface FreightDashboardHeaderProps {
onLogout?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
mobileOpened: boolean;
onToggleMobile: () => void;
}
// Every header control is a consistent 36px frosted chip — same language as the
// portal AppLayout's floating "islands".
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",
@@ -38,8 +55,11 @@ const FreightDashboardHeader = ({
onLogout,
theme,
onToggleTheme,
mobileOpened,
onToggleMobile,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
const initials =
userInitials ??
(userName
@@ -50,191 +70,147 @@ const FreightDashboardHeader = ({
.join("") ||
"U");
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isUserMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsUserMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isUserMenuOpen]);
return (
<header className="fdh-root">
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<span className="fdh-eyebrow">
{/* <span className="fdh-eyebrow-dot" />
Freight Backoffice */}
</span>
<Text
fw={700}
truncate
style={{ fontSize: "20px", lineHeight: 1.2, color: "#0f172a", letterSpacing: "-0.4px" }}
>
{pageMeta.title}
</Text>
{/* <Text size="sm" truncate style={{ color: "#94a3b8", lineHeight: 1.35 }}>
{pageMeta.subtitle}
</Text> */}
</Stack>
<Group gap={10} wrap="nowrap">
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
<AppShell.Header
withBorder={false}
// Frosted glass: fully transparent background + backdrop blur, mirroring
// the portal AppLayout. Inline styles win over Mantine's cascade layer
// (a Tailwind bg-transparent would lose to --mantine-color-body).
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group h="100%" px={20} justify="space-between" wrap="nowrap">
{/* 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}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
<Group
gap={8}
align="center"
visibleFrom="sm"
className="h-9 cursor-text rounded-full border border-edr-border bg-white px-3.5"
style={{ width: 280 }}
>
<button
type="button"
className="fdh-icon-btn"
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
<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} />
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Language" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Language">
<Languages size={18} />
</button>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<Tooltip label="Messages" withArrow openDelay={300}>
<button type="button" className="fdh-icon-btn" aria-label="Messages">
<MessageSquare size={18} />
<span className="fdh-badge">3</span>
</button>
</Tooltip>
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
<UnstyledButton
className={ISLAND}
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={17} strokeWidth={1.8} />
) : (
<Moon size={17} strokeWidth={1.8} />
)}
</UnstyledButton>
</Tooltip>
)}
<Tooltip label="Notifications" withArrow openDelay={300}>
<button
type="button"
className="fdh-icon-btn"
aria-label="Notifications"
>
<Bell size={18} />
<span className="fdh-badge">5</span>
</button>
</Tooltip>
<div className="fdh-divider" />
<Menu
position="bottom-end"
shadow="lg"
radius="md"
width={240}
opened={isUserMenuOpen}
onOpen={() => setIsUserMenuOpen(true)}
onClose={() => setIsUserMenuOpen(false)}
>
<Menu.Target>
<div className="fdh-user">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }} visibleFrom="sm">
<Text
size="sm"
fw={600}
truncate
style={{ color: "#0f172a", lineHeight: 1.25, maxWidth: 140 }}
>
{userName}
</Text>
<Text
size="xs"
truncate
style={{ color: "#94a3b8", lineHeight: 1.25, maxWidth: 140 }}
>
{userEmail ?? "Administrator"}
</Text>
</Stack>
<ChevronDown
size={16}
style={{
color: "#94a3b8",
flexShrink: 0,
transition: "transform 0.2s",
transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</div>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Group gap={10} wrap="nowrap">
<div className="fdh-avatar-ring">
<div className="fdh-avatar">{initials}</div>
</div>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate style={{ color: "#0f172a" }}>
{/* Avatar pill */}
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
<Menu.Target>
<UnstyledButton className="flex h-9 cursor-pointer items-center gap-2 rounded-full border border-edr-border bg-white py-0 pl-1 pr-2.5">
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<Box visibleFrom="sm" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} truncate className="text-edr-text!" style={{ lineHeight: 1.2, maxWidth: 120 }}>
{userName}
</Text>
{userEmail && (
<Text size="xs" truncate style={{ color: "#94a3b8" }}>
{userEmail}
</Text>
)}
</Stack>
</Group>
</Box>
<Menu.Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile");
}}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => {
setIsUserMenuOpen(false);
navigate("/dashboard/profile#signature");
}}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => {
setIsUserMenuOpen(false);
onLogout?.();
}}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Text size="xs" truncate className="text-edr-muted!" style={{ lineHeight: 1.2, maxWidth: 120, fontSize: 11 }}>
{userEmail ?? "Administrator"}
</Text>
</Box>
<ChevronDown size={15} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
{headerRight}
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text size="sm" fw={600} truncate className="text-edr-text!">
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => onLogout?.()}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
{headerRight}
</Group>
</Group>
</header>
</AppShell.Header>
);
};

View File

@@ -1,14 +1,16 @@
import { AppShell, Box } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { type ReactNode, useEffect, useState } from "react";
import { Box, Paper, MantineProvider } from "@mantine/core";
import FreightDashboardHeader from "./FreightDashboardHeader";
import FreightSidebar from "./FreightSidebar";
import { getPageMeta } from "./route-meta";
import type { SidebarSection } from "./types";
import { freightMantineTheme } from "@/theme/freight-brand";
type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "edr-theme";
const HEADER_HEIGHT = 64;
const NAVBAR_WIDTH = 280;
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
@@ -45,6 +47,9 @@ const FreightDashboardLayout = ({
children,
}: FreightDashboardLayoutProps) => {
const pageMeta = getPageMeta(activeHref);
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure(false);
const [theme, setTheme] = useState<Theme>(() =>
enableThemeToggle ? getInitialTheme() : "light",
);
@@ -52,100 +57,62 @@ const FreightDashboardLayout = ({
useEffect(() => {
if (!enableThemeToggle) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
root.classList.toggle("dark", theme === "dark");
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]);
const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
const navigate = (href: string) => {
closeMobile();
onNavigate?.(href);
};
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
<AppShell
layout="alt"
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
/>
<MantineProvider theme={freightMantineTheme}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
assume (sidebar + header stay put, content scrolls beneath). */}
<Box
style={{
display: "flex",
height: "100dvh",
overflow: "hidden",
background: "var(--mantine-color-gray-1)",
padding: "0px",
fontFamily: "'Outfit', var(--font-sans)",
}}
className="overflow-y-auto bg-edr-bg"
style={{ height: `calc(100dvh - ${HEADER_HEIGHT}px)` }}
>
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "5px" }}>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<Box
style={{
display: "flex",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: 1,
flexDirection: "column",
gap: "8px",
}}
>
<Paper
p={0}
radius="lg"
withBorder
style={{
flexShrink: 0,
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<FreightDashboardHeader
pageMeta={pageMeta}
headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName}
userEmail={userEmail}
userInitials={userInitials}
onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
/>
</Paper>
<Paper
p={{ base: 16, md: 24 }}
radius="lg"
withBorder
style={{
minHeight: 0,
flex: 1,
overflowY: "auto",
overscrollBehavior: "contain",
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
{children}
</Paper>
</Box>
</Box>
{children}
</Box>
</MantineProvider>
</>
</AppShell.Main>
</AppShell>
);
};

View File

@@ -1,320 +0,0 @@
/* ============================================================
EDR Freight — Sidebar styles
Polished, professional navigation surface.
============================================================ */
.fsb-aside {
height: 100%;
max-height: 100%;
width: 280px;
flex-shrink: 0;
border-radius: 16px;
border: 1px solid #eef1f4;
background: #ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.04),
0 8px 24px -16px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ---- Brand header ---- */
.fsb-brand {
position: relative;
display: flex;
align-items: center;
gap: 12px;
height: 80px;
padding: 0 20px;
flex-shrink: 0;
border-bottom: 1px solid #f1f5f9;
overflow: hidden;
}
.fsb-brand::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%);
pointer-events: none;
}
.fsb-logo {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 13px;
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%);
box-shadow:
0 6px 16px -4px rgba(27, 158, 122, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
flex-shrink: 0;
}
/* ---- Nav scroll region ---- */
.fsb-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 12px 12px;
display: flex;
flex-direction: column;
gap: 20px;
}
.fsb-nav::-webkit-scrollbar {
width: 6px;
}
.fsb-nav::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 3px;
}
.fsb-nav::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.fsb-nav::-webkit-scrollbar-track {
background: transparent;
}
.fsb-section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.7px;
text-transform: uppercase;
color: #94a3b8;
padding: 0 12px;
margin-bottom: 6px;
}
/* ---- Top-level item ---- */
.fsb-item {
position: relative;
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 9px 12px;
border-radius: 11px;
cursor: pointer;
color: #475569;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
text-align: left;
text-decoration: none;
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.fsb-item:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-item[data-active="true"] {
background: linear-gradient(
135deg,
rgba(34, 197, 94, 0.12) 0%,
rgba(27, 158, 122, 0.06) 100%
);
color: #1B9E7A;
font-weight: 600;
}
.fsb-item[data-active="true"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 4px 4px 0;
background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%);
}
.fsb-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Icon well ---- */
.fsb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 31px;
height: 31px;
border-radius: 9px;
flex-shrink: 0;
background: #f1f5f9;
color: #64748b;
transition: all 160ms ease;
}
.fsb-item:hover .fsb-icon {
background: #e6ebf1;
color: #334155;
}
.fsb-item[data-active="true"] .fsb-icon {
background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%);
color: #ffffff;
box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45);
}
.fsb-chevron {
flex-shrink: 0;
color: #94a3b8;
transition: transform 220ms ease;
}
.fsb-chevron-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
}
.fsb-chevron-btn:hover .fsb-chevron {
color: #64748b;
}
/* ---- Nested branch ---- */
.fsb-branch {
margin: 2px 0 2px 22px;
padding-left: 12px;
border-left: 1.5px solid #eef2f6;
display: flex;
flex-direction: column;
gap: 2px;
}
/* group header (non-navigable) */
.fsb-group {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
background: transparent;
transition: background-color 150ms ease;
}
.fsb-group:hover {
background-color: #f5f7fa;
}
.fsb-group-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #94a3b8;
}
.fsb-group[data-active="true"] .fsb-group-label {
color: #1B9E7A;
}
/* child leaf */
.fsb-child {
position: relative;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
color: #64748b;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition:
background-color 150ms ease,
color 150ms ease;
}
.fsb-child:hover {
background-color: #f5f7fa;
color: #0f172a;
}
.fsb-child[data-active="true"] {
color: #1B9E7A;
font-weight: 600;
background-color: rgba(27, 158, 122, 0.08);
}
.fsb-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
background: #cbd5e1;
transition: all 150ms ease;
}
.fsb-child:hover .fsb-dot {
background: #94a3b8;
}
.fsb-child[data-active="true"] .fsb-dot {
background: #1B9E7A;
box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16);
}
/* ---- Footer status card ---- */
.fsb-footer {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #f1f5f9;
}
.fsb-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 11px;
background: linear-gradient(135deg, #E7F8F2 0%, #f8fafc 100%);
border: 1px solid #e7f3ec;
}
.fsb-pulse {
position: relative;
width: 9px;
height: 9px;
border-radius: 50%;
background: #2DBF95;
flex-shrink: 0;
}
.fsb-pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: #2DBF95;
animation: fsb-pulse 2s ease-out infinite;
}
@keyframes fsb-pulse {
0% {
transform: scale(1);
opacity: 0.6;
}
100% {
transform: scale(2.6);
opacity: 0;
}
}

View File

@@ -1,277 +1,248 @@
import {
type MouseEvent,
AppShell,
Box,
Group,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { ChevronDown, X } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ChevronDown, Train } from "lucide-react";
import { Box, Stack, Text } from "@mantine/core";
import type { SidebarItem, SidebarSection } from "./types";
import "./FreightSidebar.css";
export interface FreightSidebarProps {
sections: SidebarSection[];
activeHref?: string;
onNavigate?: (href: string) => void;
/** Close handler for the mobile drawer (X button, hidden on desktop). */
onClose?: () => void;
}
const sidebarItemKey = (item: SidebarItem, parentKey: string) =>
item.href ?? `${parentKey}::${item.label}`;
const BRAND_LOGO = "/assets/logo.svg";
const collectSidebarHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => {
const hrefs: string[] = [];
if (item.href) hrefs.push(item.href.toLowerCase());
if (item.children?.length)
hrefs.push(...collectSidebarHrefs(item.children));
return hrefs;
});
// Active / inactive NavLink styling, expressed through the shared edr-* theme
// tokens (bridged into Tailwind in index.css). Items are pills floating on the
// page background — the navbar itself has no surface of its own.
const navClassNames = (active: boolean) =>
active
? {
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
label: "text-edr-primary-dark! font-medium! text-sm!",
section: "text-edr-primary-dark!",
}
: {
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
section: "text-edr-text!",
};
const flattenSectionItems = (sections: SidebarSection[]) =>
sections.flatMap((section) => section.items);
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
`${parentKey}/${item.href ?? item.label}/${index}`;
const collectHrefs = (items: SidebarItem[]): string[] =>
items.flatMap((item) => [
...(item.href ? [item.href.toLowerCase()] : []),
...(item.children?.length ? collectHrefs(item.children) : []),
]);
const FreightSidebar = ({
sections,
activeHref,
onNavigate,
onClose,
}: FreightSidebarProps) => {
const items = useMemo(() => flattenSectionItems(sections), [sections]);
const activePath = activeHref?.toLowerCase() ?? "";
const isHrefActive = useCallback(
(href: string) => {
const normalized = href.toLowerCase();
return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
return activePath === normalized || activePath.startsWith(`${normalized}/`);
},
[activePath],
);
const branchContainsActive = useCallback(
(branch: SidebarItem[]) =>
collectSidebarHrefs(branch).some((href) => isHrefActive(href)),
const branchActive = useCallback(
(items: SidebarItem[]) => collectHrefs(items).some(isHrefActive),
[isHrefActive],
);
const defaultExpanded = useMemo(() => {
// Branches containing the active route start expanded; manual toggles win
// afterwards (merge keeps user intent while still opening newly-active paths).
const defaultOpen = useMemo(() => {
const acc: Record<string, boolean> = {};
const walk = (entries: SidebarItem[], parentKey: string) => {
for (const entry of entries) {
if (!entry.children?.length) continue;
const key = sidebarItemKey(entry, parentKey);
const walk = (items: SidebarItem[], parentKey: string) => {
items.forEach((item, i) => {
if (!item.children?.length) return;
const key = itemKey(parentKey, item, i);
acc[key] =
branchContainsActive(entry.children) ||
(entry.href ? isHrefActive(entry.href) : false);
walk(entry.children, key);
}
(item.href ? isHrefActive(item.href) : false) ||
branchActive(item.children);
walk(item.children, key);
});
};
for (const item of items) {
if (!item.children?.length) continue;
const key = item.href ?? item.label;
acc[key] =
activePath === key.toLowerCase() ||
activePath.startsWith(`${key.toLowerCase()}/`) ||
branchContainsActive(item.children);
walk(item.children, key);
}
sections.forEach((section) => walk(section.items, section.title));
return acc;
}, [activePath, branchContainsActive, isHrefActive, items]);
const [expanded, setExpanded] =
useState<Record<string, boolean>>(defaultExpanded);
}, [sections, isHrefActive, branchActive]);
const [openMap, setOpenMap] = useState(defaultOpen);
useEffect(() => {
setExpanded((current) => ({ ...defaultExpanded, ...current }));
}, [defaultExpanded]);
setOpenMap((current) => ({ ...defaultOpen, ...current }));
}, [defaultOpen]);
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
if (onNavigate) {
event.preventDefault();
onNavigate(href);
}
};
const toggle = useCallback(
(key: string) => setOpenMap((m) => ({ ...m, [key]: !m[key] })),
[],
);
const toggleExpanded = (key: string) => {
setExpanded((current) => ({ ...current, [key]: !current[key] }));
};
const renderItem = useCallback(
(item: SidebarItem, key: string): ReactNode => {
const hasChildren = !!item.children?.length;
const renderNavBranch = (
children: SidebarItem[],
depth: number,
parentKey: string,
): ReactNode =>
children.map((child) => {
const key = sidebarItemKey(child, parentKey);
const isGroup = Boolean(child.children?.length) && !child.href;
if (isGroup) {
const isOpen = expanded[key] ?? false;
const groupActive = branchContainsActive(child.children!);
if (hasChildren) {
const isLink = !!item.href;
const active =
(isLink ? isHrefActive(item.href!) : false) ||
branchActive(item.children!);
const isOpen = openMap[key] ?? false;
return (
<div key={key}>
<button
type="button"
className="fsb-group"
data-active={groupActive}
onClick={() => toggleExpanded(key)}
>
<span className="fsb-group-label">{child.label}</span>
<ChevronDown
size={13}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
color: groupActive ? "#1B9E7A" : undefined,
// `opened` is controlled so a link-parent navigates on row click
// without collapsing; the chevron is the only toggle affordance.
<NavLink
key={key}
label={item.label}
leftSection={item.icon}
active={active}
opened={isOpen}
classNames={navClassNames(active)}
onClick={ () => toggle(key)}
rightSection={
<Box
component="span"
role="button"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(key);
}}
/>
</button>
{isOpen && (
<div className="fsb-branch">
{renderNavBranch(child.children!, depth + 1, key)}
</div>
className="flex cursor-pointer items-center"
>
<ChevronDown
size={16}
className="text-edr-muted transition-transform duration-200"
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
/>
</Box>
}
>
{item.children!.map((child, i) =>
renderItem(child, itemKey(key, child, i)),
)}
</div>
</NavLink>
);
}
if (!child.href) return null;
const childActive = isHrefActive(child.href);
if (!item.href) return null;
const active = isHrefActive(item.href);
return (
<a
<NavLink
key={key}
href={child.href}
className="fsb-child"
data-active={childActive}
onClick={(e) => navigateTo(e, child.href!)}
>
<span className="fsb-dot" />
<span className="fsb-item-label">{child.label}</span>
</a>
label={item.label}
leftSection={item.icon}
active={active}
classNames={navClassNames(active)}
onClick={() => onNavigate?.(item.href!)}
/>
);
});
},
[branchActive, isHrefActive, onNavigate, openMap, toggle],
);
const renderTopLevelItem = (item: SidebarItem) => {
if (!item.href) return null;
const hasChildren = Boolean(item.children?.length);
const itemHref = item.href.toLowerCase();
const childActive = hasChildren
? branchContainsActive(item.children!)
: false;
const isCurrentItem = hasChildren
? activePath === itemHref
: isHrefActive(itemHref);
const isActive = isCurrentItem || childActive;
const isOpen = expanded[item.href] ?? false;
return (
<Box key={item.href}>
<a
href={item.href}
className="fsb-item"
data-active={isActive}
onClick={(e) => {
if (hasChildren) {
setExpanded((current) => ({
...current,
[item.href!]: true,
}));
}
navigateTo(e, item.href!);
}}
>
{item.icon && <span className="fsb-icon">{item.icon}</span>}
<span className="fsb-item-label">{item.label}</span>
{hasChildren && (
<button
type="button"
className="fsb-chevron-btn"
aria-label={isOpen ? "Collapse section" : "Expand section"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleExpanded(item.href!);
}}
>
<ChevronDown
size={16}
className="fsb-chevron"
style={{
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
}}
/>
</button>
)}
</a>
{hasChildren && isOpen && (
<div className="fsb-branch">
{renderNavBranch(item.children!, 0, item.href)}
</div>
)}
</Box>
);
};
return (
<Box component="aside" className="fsb-aside">
<div className="fsb-brand">
<div className="fsb-logo">
<Train size={23} color="white" strokeWidth={2.1} />
</div>
<Stack gap={1} style={{ minWidth: 0, position: "relative", zIndex: 1 }}>
<Text
size="md"
fw={700}
style={{ letterSpacing: "-0.3px", lineHeight: 1.2, color: "#0f172a" }}
>
EDR Freight
</Text>
const renderedSections = useMemo(
() =>
sections.map((section) => (
<Box key={section.title}>
<Text
size="xs"
fw={600}
style={{ letterSpacing: "0.4px", color: "#94a3b8" }}
tt="uppercase"
px="sm"
mb={6}
className={ "text-edr-muted!" }
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
>
Backoffice Console
{section.title}
</Text>
</Stack>
</div>
<nav className="fsb-nav">
{sections.map((section) => (
<div key={section.title}>
<div className="fsb-section-label">{section.title}</div>
<Stack gap={3}>
{section.items.map((item) => renderTopLevelItem(item))}
</Stack>
</div>
))}
</nav>
<div className="fsb-footer">
<div className="fsb-status">
<span className="fsb-pulse" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={600} style={{ color: "#15805F", lineHeight: 1.3 }}>
All systems operational
</Text>
<Text size="10px" style={{ color: "#94a3b8", lineHeight: 1.3 }}>
EDR Platform · v1.0
</Text>
<Stack gap={2}>
{section.items.map((item, i) =>
renderItem(item, itemKey(section.title, item, i)),
)}
</Stack>
</div>
</div>
</Box>
</Box>
)),
[renderItem, sections],
);
return (
<AppShell.Navbar
withBorder={false}
// White surface + hairline right border, matching the portal AppLayout.
// Inline styles beat Mantine's cascade layer (where a Tailwind bg-* would
// lose to the navbar's default --mantine-color-body).
style={{
backgroundColor: "var(--mantine-color-edr-card-6)",
borderRight: "1px solid var(--mantine-color-edr-border-6)",
}}
>
{/* Brand — aligns with the 64px header for a continuous top edge */}
<Box className="flex h-16 shrink-0 items-center justify-between px-5">
<Group gap={10} wrap="nowrap">
<img
src={BRAND_LOGO}
alt="EDR Freight"
className="size-8 shrink-0 object-contain"
/>
<Box>
<Text
className="text-edr-primary! leading-tight"
fw={700}
fz={15}
style={{ letterSpacing: "-0.01em" }}
>
EDR Freight
</Text>
<Text
className="text-edr-muted!"
fz={10}
fw={500}
style={{ letterSpacing: "0.02em" }}
>
Backoffice Console
</Text>
</Box>
</Group>
{onClose && (
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton>
)}
</Box>
{/* Nav */}
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
<Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section>
</AppShell.Navbar>
);
};

View File

@@ -61,7 +61,28 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
prefix: "/dashboard/operations/last-mile",
meta: {
title: "Last Mile",
subtitle: "Assign vehicles to final-leg deliveries",
subtitle: "Assign vehicles to final-leg deliveries"
}
},
{
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",
},
},
{

View File

@@ -1,65 +1,36 @@
import { Group, Paper, Text } from "@mantine/core";
import { KpiStrip, type KpiItem } from "@/components/page";
import {
OverviewKpiCard,
type KpiGraphVariant,
type OverviewKpiItem,
} from "./OverviewKpiCard";
import type { OverviewKpiItem } from "./OverviewKpiCard";
/** Rotate mini-graph types per card so each strip reads as a lively mix. */
const VARIANT_CYCLE: KpiGraphVariant[] = ["area", "line", "ring"];
/** Cohesive accent rotation — gold-forward with an orange and neutral break. */
const ACCENT_CYCLE: NonNullable<OverviewKpiItem["accent"]>[] = [
"gold",
"orange",
"default",
];
/**
* Map the overview accent vocabulary onto brand / Mantine palette colors so the
* shared KpiStrip renders a flat tinted icon chip per cell — no gradients,
* gauges or sparklines.
*/
const ACCENT_COLOR: Record<string, string> = {
default: "edr-green",
emerald: "edr-green",
amber: "yellow",
rose: "red",
sky: "blue",
violet: "violet",
gold: "yellow",
orange: "orange",
};
interface OverviewKpiStripProps {
title?: string;
items: OverviewKpiItem[];
}
/** Parse a numeric magnitude out of a KPI value (handles formatted currency strings). */
function toNumber(value: number | string): number {
if (typeof value === "number") return value;
const parsed = Number(String(value).replace(/[^0-9.-]/g, ""));
return Number.isFinite(parsed) ? parsed : 0;
}
/** Clean KPI strip for the overview tabs — delegates to the shared KpiStrip. */
export function OverviewKpiStrip({ items }: OverviewKpiStripProps) {
const kpiItems: KpiItem[] = items.map((item) => ({
label: item.label,
value: item.value,
icon: item.icon,
hint: item.hint,
color: ACCENT_COLOR[item.accent ?? "default"] ?? "edr-green",
}));
export function OverviewKpiStrip({ title, items }: OverviewKpiStripProps) {
const max = Math.max(...items.map((item) => toNumber(item.value)), 0);
return (
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: "#ffffff",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
{title && (
<Text size="sm" fw={600} mb="md" c="dimmed">
{title}
</Text>
)}
<Group gap="md" align="stretch" wrap="wrap">
{items.map((item, index) => (
<OverviewKpiCard
key={item.label}
item={{
...item,
accent: ACCENT_CYCLE[index % ACCENT_CYCLE.length],
variant: item.variant ?? VARIANT_CYCLE[index % VARIANT_CYCLE.length],
progress:
item.progress ?? (max > 0 ? toNumber(item.value) / max : 0),
}}
/>
))}
</Group>
</Paper>
);
return <KpiStrip items={kpiItems} />;
}

View File

@@ -47,11 +47,11 @@ export function OverviewPageHeader({
data={RANGE_OPTIONS}
size="sm"
radius="lg"
color="green"
color="edr-green"
/>
<ActionIcon
variant="light"
color="green"
color="edr-green"
size="lg"
radius="lg"
aria-label="Refresh dashboard"

View File

@@ -49,7 +49,7 @@ export function OverviewQuickLinks() {
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="green" size="lg" radius="md">
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>

View File

@@ -78,7 +78,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
<Stack gap="md" pos="relative">
{isFetching && (
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
<Loader size="sm" color="green" />
<Loader size="sm" color="edr-green" />
</Center>
)}

View File

@@ -16,7 +16,7 @@
font-weight: 600;
}
.ov-seg-label[data-active] {
color: #15805f;
color: var(--mantine-color-edr-green-7);
}
/* ---- Premium tab bar ---- */
@@ -47,10 +47,8 @@
box-shadow: 0 2px 10px -4px rgba(15, 23, 42, 0.18);
}
.ov-tab[data-active] {
background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%) !important;
background: var(--mantine-color-edr-green-5) !important;
color: #ffffff !important;
box-shadow: 0 10px 20px -8px rgba(27, 158, 122, 0.55);
transform: translateY(-1px);
}
.ov-tab[data-active]:hover {
color: #ffffff;

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" 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

@@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({
Cancel
</Button>
<Button
color="green"
color="edr-green"
onClick={handleSave}
disabled={isLoading || isSaving}
leftSection={

View File

@@ -1,5 +1,5 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -99,13 +99,16 @@ const RuleEngineCardGrid = ({
<Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
</div>
<Card key={index} p="lg">
<Group gap="sm" mb="md">
<Skeleton height={44} width={44} radius="md" />
<Stack gap={6} style={{ flex: 1 }}>
<Skeleton height={14} width="70%" radius="sm" />
<Skeleton height={10} width="40%" radius="sm" />
</Stack>
</Group>
<Skeleton height={12} radius="sm" mb={8} />
<Skeleton height={12} width="80%" radius="sm" />
</Card>
))}
</SimpleGrid>
@@ -124,8 +127,8 @@ const RuleEngineCardGrid = ({
);
}
const avatarBg = "#f1f5f9";
const avatarText = "#475569";
const avatarBg = "var(--mantine-color-gray-1)";
const avatarText = "var(--mantine-color-gray-7)";
return (
<Stack gap="md" p="md">
@@ -152,24 +155,7 @@ const RuleEngineCardGrid = ({
<Card
key={record.id}
p="lg"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
style={{ display: "flex", flexDirection: "column" }}
>
<Group justify="space-between" align="flex-start" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
@@ -207,7 +193,7 @@ const RuleEngineCardGrid = ({
</Group>
{(subtitle || presentation.detailColumns.length > 0) && (
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
<Stack gap="xs" mb="md" style={{ flex: 1 }}>
{subtitle && (
<Group gap="xs">
<Text size="xs" c="dimmed" fw={500}>
@@ -234,7 +220,7 @@ const RuleEngineCardGrid = ({
</Stack>
)}
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
<Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<RuleEngineRecordActions
record={record}
config={config}

View File

@@ -119,15 +119,6 @@ const resolveSelectValue = (
const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
input: {
borderColor: "#e2e8f0",
background: "white",
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
"&:focus": {
borderColor: "var(--freight-brand)",
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
},
},
} as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
@@ -228,8 +219,8 @@ const RuleEngineFormDialog = ({
px="md"
style={{
minHeight: 42,
background: "#f8fafc",
border: "1px solid #e2e8f0",
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
}}
>
@@ -240,7 +231,7 @@ const RuleEngineFormDialog = ({
checked={Boolean(values[field.name])}
onChange={(e) => setField(field.name, e.currentTarget.checked)}
size="md"
color="green"
color="edr-green"
/>
</Group>
);
@@ -387,7 +378,7 @@ const RuleEngineFormDialog = ({
) : undefined
}
radius="md"
color="green"
color="edr-green"
variant="filled"
fw={600}
size="md"

View File

@@ -52,7 +52,7 @@ const RuleEngineToolbar = ({
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
size="sm"
radius="lg"
color="green"
color="edr-green"
data={[
{
value: "table",
@@ -101,7 +101,7 @@ const RuleEngineToolbar = ({
leftSection={<Plus size={18} />}
size="sm"
radius="lg"
color="green"
color="edr-green"
variant="filled"
fw={600}
style={{ whiteSpace: "nowrap" }}

View File

@@ -39,7 +39,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const active = Boolean(value);
return (
<Badge
color={active ? "green" : "gray"}
color={active ? "edr-green" : "gray"}
variant={active ? "filled" : "light"}
size="sm"
radius="md"
@@ -53,7 +53,7 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
const status = String(value);
const color =
status === "LIVE"
? "green"
? "edr-green"
: status === "DRAFT"
? "yellow"
: status === "PENDING_APPROVAL"

View File

@@ -40,7 +40,7 @@ export const ruleEngineCard = {
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar:
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground",
detailLabel:

View File

@@ -447,27 +447,27 @@ export function AllocateBookingWizard({
if (key === "bookings") {
if (previewResult) {
return (
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
<Badge variant="light" color={previewResult.valid ? "edr-green" : "red"} radius="sm">
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allBookingIds.length ? (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{allBookingIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
<Badge variant="light" color={containerComplete ? "edr-green" : "yellow"} radius="sm">
{containerUnits.length} units
</Badge>
);
@@ -577,7 +577,7 @@ export function AllocateBookingWizard({
size="sm"
/>
<Button
color="green"
color="edr-green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
@@ -648,7 +648,7 @@ export function AllocateBookingWizard({
<Group>
{!hasContainerStep ? (
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -657,7 +657,7 @@ export function AllocateBookingWizard({
</Button>
) : (
<Button
color="green"
color="edr-green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
@@ -692,7 +692,7 @@ export function AllocateBookingWizard({
)}
<Group>
<Button
color="green"
color="edr-green"
radius="md"
loading={assign.isPending || create.isPending}
onClick={handleAssign}
@@ -732,7 +732,7 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
@@ -741,14 +741,14 @@ export function AllocateBookingWizard({
</Text>
<Text size="sm" c="dimmed">
Booking {booking.reference} is scheduled on train{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
</Text>
.
</Text>
<Group mt="sm">
<Button
color="green"
color="edr-green"
radius="md"
onClick={() => {
onClose();
@@ -777,14 +777,14 @@ export function AllocateBookingWizard({
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to finalize</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan, moves the schedule to{" "}
<Text span fw={600} c="green.7">
<Text span fw={600} c="edr-green.7">
SCHEDULED
</Text>
, and completes the booking allocation.
@@ -794,7 +794,7 @@ export function AllocateBookingWizard({
</Paper>
<Group>
<Button
color="green"
color="edr-green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
@@ -852,7 +852,7 @@ export function AllocateBookingWizard({
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
style={{ color: "var(--mantine-color-edr-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
@@ -884,7 +884,7 @@ export function AllocateBookingWizard({
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
c={previewResult.valid ? "edr-green.8" : "red.7"}
leftSection={
<Box
w={8}
@@ -892,7 +892,7 @@ export function AllocateBookingWizard({
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
? "var(--mantine-color-edr-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
@@ -941,7 +941,7 @@ export function AllocateBookingWizard({
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
gradient={{ from: "edr-green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
@@ -959,9 +959,9 @@ export function AllocateBookingWizard({
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
sections={[{ value: progressPct, color: "edr-green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
<Text ta="center" size="xs" fw={700} c="edr-green.7">
{progressPct}%
</Text>
}

View File

@@ -120,7 +120,7 @@ export function ContainerPlacementGrid({
value={progress}
size="sm"
radius="xl"
color={issues.length ? "yellow" : "green"}
color={issues.length ? "yellow" : "edr-green"}
/>
</Stack>
</Paper>
@@ -135,7 +135,7 @@ export function ContainerPlacementGrid({
</Stack>
) : (
<Badge
color="green"
color="edr-green"
variant="light"
size="sm"
w="fit-content"
@@ -163,7 +163,7 @@ export function ContainerPlacementGrid({
{unit.label} · {unit.containerTypeCode} · {unit.grossWeightTons}T
</Text>
</Stack>
<Badge size="sm" variant="light" color={isComplete ? "green" : "gray"}>
<Badge size="sm" variant="light" color={isComplete ? "edr-green" : "gray"}>
{isComplete ? "Ready" : "Pending"}
</Badge>
</Group>

View File

@@ -37,14 +37,14 @@ function EligibleBookingRow({
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-green-3)" : "var(--mantine-color-gray-2)"
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-green-0)" : "white",
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="green" />
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<Package size={14} />
@@ -205,7 +205,7 @@ export function EligibleBookingsPanel({
</Text>
</Stack>
<Group gap="xs" onClick={(e) => e.stopPropagation()}>
<Badge variant="light" color="green">
<Badge variant="light" color="edr-green">
{selectedInBucket.length} selected
</Badge>
<Button

View File

@@ -43,7 +43,7 @@ export function FleetAvailabilitySummary({
</Text>
</Stack>
</Group>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "green"}>
<Badge variant="light" color={totalShortfall > 0 ? "yellow" : "edr-green"}>
{fillRate}% fleet coverage
</Badge>
</Group>
@@ -59,7 +59,7 @@ export function FleetAvailabilitySummary({
value={fillRate}
size="sm"
radius="xl"
color={totalShortfall > 0 ? "yellow" : "green"}
color={totalShortfall > 0 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}
@@ -86,7 +86,7 @@ export function FleetAvailabilitySummary({
{row.shortfall}
</Badge>
) : (
<Text size="sm" c="green">
<Text size="sm" c="edr-green">
0
</Text>
)}

View File

@@ -143,7 +143,7 @@ export function RouteCorridorTrack({
fw={passed ? 700 : 600}
ta="center"
lineClamp={2}
c={passed ? "green.8" : "dimmed"}
c={passed ? "edr-green.8" : "dimmed"}
>
{station.label}
</Text>
@@ -172,7 +172,7 @@ export function RouteCorridorTrack({
<Button
size="compact-xs"
radius="md"
color={isFinal ? "teal" : "green"}
color={isFinal ? "teal" : "edr-green"}
variant={isFinal ? "filled" : "light"}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}

View File

@@ -26,7 +26,7 @@ interface ScheduleBatchPanelProps {
}
const windowColor: Record<string, string> = {
OPEN: "green",
OPEN: "edr-green",
FULL: "orange",
CLOSED: "gray",
};
@@ -68,7 +68,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Group justify="space-between" align="center" mb="md" wrap="wrap">
<Group gap="sm">
<ThemeIcon size={36} radius="md" variant="light" color="green">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
<Layers size={18} />
</ThemeIcon>
<div>
@@ -90,7 +90,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-sm"
variant="light"
color="green"
color="edr-green"
leftSection={<PlayCircle size={15} />}
loading={actions.runBatch.isPending}
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
@@ -169,7 +169,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Button
size="compact-xs"
variant="light"
color="green"
color="edr-green"
leftSection={<CheckCircle2 size={13} />}
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
>
@@ -228,7 +228,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
Cancel
</Button>
<Button
color="green"
color="edr-green"
disabled={!moveTarget}
loading={actions.moveSchedule.isPending}
onClick={() => {

View File

@@ -1,4 +1,3 @@
import { ArrowRight, Package, Train } from "lucide-react";
import {
Badge,
Button,
@@ -8,6 +7,7 @@ import {
Tabs,
Text,
} from "@mantine/core";
import { ArrowRight, Package, Train } from "lucide-react";
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
@@ -46,7 +46,7 @@ export function ScheduleBookingsStep({
defaultValue={assignedBookings.length ? "on-train" : "add"}
radius="md"
variant="pills"
color="green"
color="edr-green"
>
<Tabs.List mb="md">
<Tabs.Tab
@@ -54,7 +54,7 @@ export function ScheduleBookingsStep({
leftSection={<Train size={14} />}
rightSection={
assignedBookings.length ? (
<Badge size="xs" variant="light" color="green" circle>
<Badge size="xs" variant="light" color="edr-green" circle>
{assignedBookings.length}
</Badge>
) : undefined
@@ -76,9 +76,9 @@ export function ScheduleBookingsStep({
justify="space-between"
p="sm"
style={{
border: "1px solid var(--mantine-color-green-2)",
border: "1px solid var(--mantine-color-edr-green-2)",
borderRadius: 12,
background: "var(--mantine-color-green-0)",
background: "var(--mantine-color-edr-green-0)",
}}
>
<Stack gap={4}>
@@ -87,7 +87,7 @@ export function ScheduleBookingsStep({
{booking.reference}
</Text>
{booking.weightTons != null ? (
<Badge variant="outline" size="xs" color="green">
<Badge variant="outline" size="xs" color="edr-green">
{booking.weightTons}T
</Badge>
) : null}
@@ -97,7 +97,7 @@ export function ScheduleBookingsStep({
Assigned to this consist
</Text>
<ArrowRight size={12} />
<Text size="xs" c="green.7" fw={500}>
<Text size="xs" c="edr-green.7" fw={500}>
Ready for wagon plan
</Text>
</Group>

View File

@@ -3,7 +3,7 @@ import { Badge } from "@mantine/core";
const STATUS_COLORS: Record<string, string> = {
DRAFT: "gray",
SCHEDULED: "blue",
DISPATCHED: "green",
DISPATCHED: "edr-green",
ARRIVED: "teal",
CANCELLED: "red",
};
@@ -34,7 +34,7 @@ export function SchedulingStatusBadge({ status }: { status?: string | null }) {
HOLDING: "yellow",
ELIGIBLE: "blue",
SCHEDULED: "indigo",
DISPATCHED: "green",
DISPATCHED: "edr-green",
};
return (
<Badge variant="light" color={colors[status] ?? "gray"} size="sm">

View File

@@ -54,7 +54,7 @@ export function PreviewSummary({
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (
<Paper p="md" radius="xl" withBorder bg="green.0">
<Paper p="md" radius="xl" withBorder bg="edr-green.0">
<Text size="sm" fw={600} mb="sm">
Plan summary
</Text>

View File

@@ -49,7 +49,7 @@ export function SchedulingWorkflowHeader({
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start">
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
<ThemeIcon size={42} radius="xl" variant="gradient" gradient={{ from: "edr-green", to: "teal", deg: 135 }}>
<Icon size={20} />
</ThemeIcon>
<Stack gap={4}>
@@ -63,7 +63,7 @@ export function SchedulingWorkflowHeader({
) : null}
</Stack>
</Group>
<Badge size="lg" variant="light" color="green">
<Badge size="lg" variant="light" color="edr-green">
Step {activeStep + 1} of {totalSteps}
</Badge>
</Group>
@@ -83,7 +83,7 @@ export function SchedulingWorkflowHeader({
{progress}%
</Text>
</Group>
<Progress value={progress} size="sm" radius="xl" color="green" />
<Progress value={progress} size="sm" radius="xl" color="edr-green" />
</Stack>
</Paper>
);

View File

@@ -618,18 +618,18 @@ export function TrainCompositionDiagram({
p="sm"
style={{
borderRadius: 12,
background: "linear-gradient(135deg, var(--mantine-color-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-green-1)",
background: "linear-gradient(135deg, var(--mantine-color-edr-green-0), #F2FBF7)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group justify="space-between" mb={6}>
<Group gap={6} wrap="nowrap">
<Gauge size={14} color={freightBrand.primary} />
<Text size="xs" fw={700} c="green.8">
<Text size="xs" fw={700} c="edr-green.8">
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
</Text>
</Group>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "green.7"}>
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
{stats.pullUtil}%
</Text>
</Group>
@@ -639,7 +639,7 @@ export function TrainCompositionDiagram({
radius="xl"
striped={stats.pullUtil > 95}
animated={stats.pullUtil > 95}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "green"}
color={stats.pullUtil > 95 ? "red" : stats.pullUtil > 80 ? "yellow" : "edr-green"}
/>
</Box>
) : null}

View File

@@ -133,7 +133,7 @@ export function WagonPlanGrid({
value={utilization}
size="sm"
radius="xl"
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "green"}
color={utilization > 95 ? "red" : utilization > 80 ? "yellow" : "edr-green"}
/>
</Stack>
) : null}

View File

@@ -123,13 +123,13 @@ export function WorkflowStep({
<Text
size="xs"
fw={700}
c={isComplete || isActive ? "green.7" : "dimmed"}
c={isComplete || isActive ? "edr-green.7" : "dimmed"}
style={{ letterSpacing: 0.6 }}
>
STEP {index + 1}
</Text>
{isComplete ? (
<Badge size="xs" variant="light" color="green" radius="sm">
<Badge size="xs" variant="light" color="edr-green" radius="sm">
Done
</Badge>
) : null}
@@ -155,10 +155,10 @@ export function WorkflowStep({
height: 28,
borderRadius: 8,
background: open
? "var(--mantine-color-green-0)"
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-gray-1)",
color: open
? "var(--mantine-color-green-7)"
? "var(--mantine-color-edr-green-7)"
: "var(--mantine-color-gray-6)",
}}
>
@@ -209,7 +209,7 @@ export function WorkflowRail({ children }: { children: ReactNode }) {
bottom: 24,
width: 2,
background:
"linear-gradient(180deg, var(--mantine-color-green-3) 0%, var(--mantine-color-gray-3) 100%)",
"linear-gradient(180deg, var(--mantine-color-edr-green-3) 0%, var(--mantine-color-gray-3) 100%)",
borderRadius: 2,
pointerEvents: "none",
}}

View File

@@ -27,7 +27,7 @@ export const PIPELINE_STAGES: ReadonlyArray<{
{
key: "allocated",
label: "Allocated",
color: "green",
color: "edr-green",
hint: "Assigned to wagons on the train",
},
{
@@ -137,7 +137,7 @@ export function BookingPipeline({
}
const WINDOW_META: Record<string, { color: string; label: string; pulse: boolean }> = {
OPEN: { color: "green", label: "Window open", pulse: true },
OPEN: { color: "edr-green", label: "Window open", pulse: true },
FULL: { color: "orange", label: "Full", pulse: false },
CLOSED: { color: "gray", label: "Closed", pulse: false },
};

View File

@@ -95,7 +95,7 @@ export const AssignedBookingsPanel = ({
}}
>
<Group gap={8} wrap="nowrap" align="flex-start">
<ThemeIcon size={30} radius="md" variant="light" color="green">
<ThemeIcon size={30} radius="md" variant="light" color="edr-green">
<Package size={16} />
</ThemeIcon>
<Box style={{ flex: 1, minWidth: 0 }}>
@@ -107,7 +107,7 @@ export const AssignedBookingsPanel = ({
<Badge
size="xs"
variant="light"
color="green"
color="edr-green"
leftSection={<TrainFront size={9} />}
>
{wagonCountByBooking.get(booking.id)}

View File

@@ -43,7 +43,7 @@ function InfoRow({
return (
<Group justify="space-between" wrap="nowrap" gap="md">
<Group gap={8} wrap="nowrap">
<ThemeIcon size={28} radius="md" variant="light" color="green">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text size="sm" c="dimmed">
@@ -146,7 +146,7 @@ export const BookingDetailModal = ({
bookingWagons.length ? (
<Group gap={4} justify="flex-end">
{bookingWagons.map((w) => (
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
#{w.sequenceNo}
</Badge>
))}

View File

@@ -29,7 +29,7 @@ interface CompositionBookingTabsProps {
type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed";
const TAB_META: Record<TabKey, { label: string; icon: LucideIcon; color: string }> = {
assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" },
assigned: { label: "Assigned to train", icon: PackageCheck, color: "edr-green" },
unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" },
payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" },
expired: { label: "Expired bookings", icon: XCircle, color: "red" },
@@ -152,7 +152,7 @@ export const CompositionBookingTabs = ({
value={tab}
onChange={(v) => v && setTab(v as TabKey)}
variant="default"
color="green"
color="edr-green"
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
>
<Tabs.List grow>
@@ -220,7 +220,7 @@ export const CompositionBookingTabs = ({
}}
>
<Group gap={5} wrap="nowrap">
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
<PackageCheck size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" c="dimmed">
{assignedCount} on train
</Text>

View File

@@ -60,7 +60,7 @@ export const RemoveBookingConfirmModal = ({
<Text fw={800} size="sm">
{target?.reference ?? "Booking"}
</Text>
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
<Badge variant="light" color="edr-green" leftSection={<TrainFront size={10} />}>
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
</Badge>
</Group>

View File

@@ -165,7 +165,7 @@ export const TrainConsistView = ({
{selectedWagon ? (
<Box>
<Group gap={6} mb={6} wrap="nowrap">
<Badge variant="light" color="green" radius="sm">
<Badge variant="light" color="edr-green" radius="sm">
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">

View File

@@ -48,18 +48,18 @@ const YardFleetBanner = ({ fleetAtOrigin }: { fleetAtOrigin: FleetAvailabilityRo
py={6}
style={{
borderRadius: 8,
background: "var(--mantine-color-green-0)",
border: "1px solid var(--mantine-color-green-1)",
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Group gap={5} wrap="nowrap">
<MapPin size={13} color="var(--mantine-color-green-7)" />
<Text size="xs" fw={700} c="green.8">
<MapPin size={13} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8">
Origin yard
</Text>
</Group>
{fleetAtOrigin.map((row) => (
<Badge key={row.wagonTypeId} size="sm" variant="light" color="green">
<Badge key={row.wagonTypeId} size="sm" variant="light" color="edr-green">
{row.wagonTypeCode}: {row.available}
</Badge>
))}
@@ -148,7 +148,7 @@ export const UnassignedBookingsPanel = ({
}
style={{
cursor: "pointer",
borderColor: isActive ? "var(--mantine-color-green-5)" : undefined,
borderColor: isActive ? "var(--mantine-color-edr-green-5)" : undefined,
}}
>
<Stack gap={6}>
@@ -162,7 +162,7 @@ export const UnassignedBookingsPanel = ({
{booking.reference}
</Text>
{booking.priorityScore ? (
<Badge size="xs" color="green">
<Badge size="xs" color="edr-green">
P{booking.priorityScore}
</Badge>
) : null}
@@ -194,7 +194,7 @@ export const UnassignedBookingsPanel = ({
<Button
size="xs"
variant="light"
color="green"
color="edr-green"
disabled={!fits}
onClick={(e) => {
e.stopPropagation();

View File

@@ -47,7 +47,7 @@ export const WagonCard = ({
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
<Group justify="space-between">
<Group gap={6}>
<ThemeIcon size={28} radius="md" variant="white" color="green">
<ThemeIcon size={28} radius="md" variant="white" color="edr-green">
<TrainFront size={16} />
</ThemeIcon>
<div>
@@ -55,7 +55,7 @@ export const WagonCard = ({
<Text size="sm" fw={800}>
Wagon #{wagon.sequenceNo}
</Text>
<Badge size="xs" variant="light" color="green">
<Badge size="xs" variant="light" color="edr-green">
{wagonType}
</Badge>
</Group>
@@ -142,7 +142,7 @@ export const WagonCard = ({
</Group>
<Progress
value={Math.min(weightPercent, 100)}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "edr-green"}
size="sm"
radius="xl"
/>

View File

@@ -32,7 +32,7 @@ const STATUS_META: Record<
{ color: string; dot: string; label?: string }
> = {
DRAFT: { color: "gray", dot: "var(--mantine-color-gray-5)" },
SCHEDULED: { color: "green", dot: freightBrand.primary },
SCHEDULED: { color: "edr-green", dot: freightBrand.primary },
DISPATCHED: { color: "teal", dot: "var(--mantine-color-teal-6)" },
ARRIVED: { color: "blue", dot: "var(--mantine-color-blue-6)" },
CANCELLED: { color: "red", dot: "var(--mantine-color-red-6)" },

View File

@@ -2,7 +2,7 @@ import type { MantineTheme } from "@mantine/core";
export const schedulingWorkflow = {
stepper: {
color: "green" as const,
color: "edr-green" as const,
iconSize: 32,
size: "sm" as const,
},
@@ -12,11 +12,11 @@ export const schedulingWorkflow = {
withBorder: true,
},
heroGradient: (theme: MantineTheme) =>
`linear-gradient(135deg, ${theme.colors.green[0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
`linear-gradient(135deg, ${theme.colors["edr-green"][0]} 0%, ${theme.white} 55%, ${theme.colors.gray[0]} 100%)`,
workflowGradient: (theme: MantineTheme) =>
`linear-gradient(180deg, ${theme.white} 0%, ${theme.colors.gray[0]} 100%)`,
accentColor: "green" as const,
successColor: "green" as const,
accentColor: "edr-green" as const,
successColor: "edr-green" as const,
warningColor: "yellow" as const,
};

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Anchor, Breadcrumbs as MantineBreadcrumbs, Text } from "@mantine/core";
import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
export interface BreadcrumbItem {
label: string;
@@ -12,45 +12,48 @@ export interface BreadcrumbsProps {
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className="flex items-center text-sm text-slate-500"
>
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[var(--freight-brand)]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
</Link>
// The dashboard root is always the first crumb; callers pass only the trail
// beyond it.
const crumbs: BreadcrumbItem[] = [{ label: "Dashboard", href: "/" }, ...items];
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<MantineBreadcrumbs
separator={<ChevronRight size={14} aria-hidden />}
separatorMargin="xs"
styles={{
root: { flexWrap: "wrap", rowGap: 4 },
separator: { color: "var(--mantine-color-edr-muted-5)" },
}}
>
{crumbs.map((item, index) => {
const isLast = index === crumbs.length - 1;
if (item.href && !isLast) {
return (
<Anchor
key={`${item.label}-${index}`}
component={Link}
to={item.href}
size="sm"
c="dimmed"
>
{item.label}
</Anchor>
);
}
return (
<Fragment key={`${item.label}-${i}`}>
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[var(--freight-brand)]"
>
{item.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className="font-medium text-slate-900"
>
{item.label}
</span>
)}
</Fragment>
<Text
key={`${item.label}-${index}`}
size="sm"
fw={isLast ? 600 : 400}
c={isLast ? "edr-text" : "dimmed"}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</Text>
);
})}
</nav>
</MantineBreadcrumbs>
);
}

View File

@@ -56,7 +56,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
return (
<>
<Button
color="green"
color="edr-green"
size="sm"
radius="lg"
leftSection={<Plus size={16} />}
@@ -91,7 +91,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Button variant="default" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
<Button color="edr-green" loading={assign.isPending} onClick={handleAssign}>
Assign
</Button>
</Group>

View File

@@ -15,7 +15,7 @@ const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
ISSUED: 'orange',
PARTIALLY_PAID: 'yellow',
PAID: 'green',
PAID: 'edr-green',
CANCELLED: 'gray',
};
@@ -175,7 +175,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
<Button
variant="light"
color="green"
color="edr-green"
leftSection={<DoorOpen size={16} />}
loading={gateClear.isPending}
onClick={handleGateClearance}

View File

@@ -205,7 +205,7 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
<Button color="edr-green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
Save report
</Button>
</Group>

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
import { ClipboardCheck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
@@ -23,10 +25,12 @@ import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
@@ -42,6 +46,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: { inspectedCount: number; skippedCount: number };
};
const r = res.data;
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
@@ -92,15 +129,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b>
</Text>
<Button
size="compact-sm"
variant="light"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
allSelected={allSelected}
someSelected={someSelected}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal

View File

@@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps {
}
const ORANGE = '#f08c00';
const GREEN = '#5bbf4a';
const GREEN = '#22c55e'; // green from bookings
/** Inventory lifecycle status series — alternating orange / light green. */
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
const STATUS_SERIES = [
{ key: 'stored', label: 'Stored', color: ORANGE },
{ key: 'reserved', label: 'Reserved', color: GREEN },
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
{ key: 'loaded', label: 'Loaded', color: GREEN },
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
] as const;
type Granularity = 'week' | 'month' | 'year';
@@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
outerRadius={95}
paddingAngle={2}
>
{statusData.map((entry, i) => (
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
{statusData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip />

View File

@@ -1,4 +1,5 @@
import { Stack, Table, Text } from '@mantine/core';
import { Stack, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import type { InventoryInquiryResult } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
@@ -16,70 +17,61 @@ const itemDescriptor = (result: InventoryInquiryResult) => {
return '—';
};
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
if (results.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No matching items. Adjust your search to locate cargo, containers or goods.
const columns: ColumnDef<InventoryInquiryResult>[] = [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
</Text>
);
}
),
},
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
{
id: 'warehouse',
header: 'Warehouse',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
{row.original.warehouse?.code && (
<Text size="xs" c="dimmed">
{row.original.warehouse.code}
</Text>
)}
</Stack>
),
},
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'ready',
header: 'Ready',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
},
];
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
return (
<Table.ScrollContainer minWidth={1100}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th>Ready</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{results.map((result) => (
<Table.Tr key={result.id}>
<Table.Td>
<Text size="sm" fw={600}>
{result.bookingNumber ?? result.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{result.customerName ?? '—'}</Table.Td>
<Table.Td>{itemDescriptor(result)}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{result.warehouse?.name ?? '—'}</Text>
{result.warehouse?.code && (
<Text size="xs" c="dimmed">
{result.warehouse.code}
</Text>
)}
</Stack>
</Table.Td>
<Table.Td>{result.yard?.name ?? '—'}</Table.Td>
<Table.Td>{result.zone?.name ?? '—'}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={result.status} />
</Table.Td>
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
<Table.Td>{formatNumber(result.weight)}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={results}
status="success"
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -1,5 +1,7 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { useMemo } from 'react';
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
@@ -14,6 +16,14 @@ interface WarehouseInventoryTableProps {
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void;
allSelected?: boolean;
someSelected?: boolean;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -28,7 +38,7 @@ const actionColor: Record<InventoryAction, string> = {
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'green',
dispatch: 'edr-green',
'ready-for-pickup': 'orange',
release: 'yellow',
deliver: 'green',
@@ -42,116 +52,124 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No inventory items found.
</Text>
);
}
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
() => [
{
id: 'booking',
header: 'Booking',
cell: ({ row }) =>
row.original.bookingId ? (
<Tooltip label={row.original.bookingId} withArrow>
<Text size="sm" fw={600}>
{row.original.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: 'facility',
header: 'Facility',
cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—',
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{
id: 'item',
header: 'Item',
cell: ({ row }) => {
const kind = itemKind(row.original);
return (
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
);
},
},
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'status',
header: 'Status',
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
},
{
id: 'arrived',
header: 'Arrived',
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
const busy = busyId === item.id;
const nextAction = INVENTORY_NEXT_ACTION[item.status];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
],
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview],
);
return (
<Table.ScrollContainer minWidth={1150}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
<Table.Th>Zone</Table.Th>
<Table.Th>Item</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => {
const kind = itemKind(item);
const busy = busyId === item.id;
const nextAction = getNextInventoryAction(item);
return (
<Table.Tr key={item.id}>
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>
<Text size="sm" fw={600}>
{item.bookingId.slice(0, 8)}
</Text>
</Tooltip>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '—'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '—'}</Table.Td>
<Table.Td>{item.yard?.code ?? '—'}</Table.Td>
<Table.Td>{item.zone?.code ?? '—'}</Table.Td>
<Table.Td>
<Badge color={kind.color} variant="light" size="sm" radius="md">
{kind.label}
</Badge>
</Table.Td>
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>
<InventoryStatusBadge status={item.status} />
</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{nextAction && (
<Button
size="compact-xs"
variant="light"
color={actionColor[nextAction]}
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
<ArrowRightLeft size={16} />
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
{onFeePreview && (
<Tooltip label="Storage / Demurrage preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
<Coins size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={items}
status="success"
emptyMessage="No inventory items found."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { ActionIcon, Anchor, Group, Table, Text } from '@mantine/core';
import { ActionIcon, Group, Text } from '@mantine/core';
import { Eye, Pencil } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { useStations } from '@/hooks/useStations';
import type { Warehouse } from '@/types/warehouse';
@@ -20,73 +21,91 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
[stations],
);
if (warehouses.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
No warehouses found.
</Text>
);
}
const columns: ColumnDef<Warehouse>[] = [
{
id: 'code',
header: 'Code',
cell: ({ row }) => (
<Text
fw={600}
size="sm"
c="edr-green.7"
style={{ cursor: 'pointer' }}
onClick={() => onView(row.original)}
>
{row.original.code}
</Text>
),
},
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
{
id: 'facility',
header: 'Facility',
cell: ({ row }) => {
const name = row.original.stationId
? stationNameById.get(row.original.stationId)
: undefined;
return name ? (
<Text size="sm" fw={500}>
{name}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
);
},
},
{
id: 'type',
header: 'Type',
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => row.original.locationName ?? '—',
},
{
id: 'weight',
header: 'Weight (cur / cap)',
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
},
{
id: 'containers',
header: 'Containers (cur / cap)',
cell: ({ row }) =>
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
},
{
id: 'status',
header: 'Status',
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<ActionIcon variant="subtle" color="gray" onClick={() => onView(row.original)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Code</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Weight (cur / cap)</Table.Th>
<Table.Th>Containers (cur / cap)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{warehouses.map((warehouse) => (
<Table.Tr key={warehouse.id}>
<Table.Td>
<Anchor fw={600} size="sm" onClick={() => onView(warehouse)}>
{warehouse.code}
</Anchor>
</Table.Td>
<Table.Td>{warehouse.name}</Table.Td>
<Table.Td>
{warehouse.stationId && stationNameById.get(warehouse.stationId) ? (
<Text size="sm" fw={500}>
{stationNameById.get(warehouse.stationId)}
</Text>
) : (
<Text size="sm" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<WarehouseTypeBadge type={warehouse.type} />
</Table.Td>
<Table.Td>{warehouse.locationName ?? '—'}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Table.Td>
<Table.Td>{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Table.Td>
<Table.Td>
<WarehouseStatusBadge status={warehouse.status} />
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
<Pencil size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<DataTable
columns={columns}
data={warehouses}
status="success"
onRowClick={(warehouse) => onView(warehouse)}
emptyMessage="No warehouses found."
containerClassName="border-0 shadow-none"
/>
);
}

View File

@@ -25,7 +25,7 @@ export function WarehouseTypeBadge({ type }: { type: WarehouseType }) {
}
export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
const color = status === 'ACTIVE' ? 'green' : 'gray';
const color = status === 'ACTIVE' ? 'edr-green' : 'gray';
return (
<Badge color={color} variant="light" size="sm" radius="md" tt="uppercase" fw={600} style={badgeStyle}>
{status}
@@ -34,14 +34,14 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',
READY_FOR_LOADING: 'cyan',
LOADED: 'teal',
DISPATCHED: 'green',
READY_FOR_PICKUP: 'orange',
DELIVERED: 'green',
DISPATCHED: 'edr-green',
DELIVERED: 'edr-green',
};
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {