From 2b4da2397ce4e9c5582bc5c371476ef80757fe5f Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 19 Jun 2026 16:02:12 +0300 Subject: [PATCH 01/11] style: ui fix to the auth shell --- .../portal/src/components/auth/AuthShell.tsx | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx index 96d55bdd0..5a4adf587 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx @@ -11,14 +11,24 @@ export const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; const LeftPanelDecor = () => ( -
+
{[0, 1, 2, 3, 4, 5].map((ring) => ( - + ))}
@@ -26,12 +36,23 @@ const LeftPanelDecor = () => ( ); const RightPanelDecor = () => ( -
+
- + - + @@ -47,18 +68,25 @@ export interface AuthShellProps { taglineBody?: string; } -const LeftPanel = ({ tagline, taglineBody }: Pick) => ( -
+const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( +
Ethio Djibouti Railway -
+
- EDR Freight + EDR Freight ( ); -export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) { +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { return ( <> - - - -
-
+
@@ -136,7 +170,7 @@ export default function AuthShell({ children, tagline, taglineBody }: AuthShellP
-
+
{children}
From 9caf9bbd59a90530b45b2c1a799e31b0d669b57a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 19 Jun 2026 16:24:20 +0300 Subject: [PATCH 02/11] style: some clean up --- .../layout/FreightDashboardHeader.tsx | 21 ++- .../layout/FreightDashboardLayout.tsx | 69 +++----- .../src/components/layout/FreightSidebar.css | 63 ++++--- .../src/components/layout/FreightSidebar.tsx | 49 ++--- .../backoffice/src/pages/auth/LoginPage.tsx | 145 ++++++++++----- .../backoffice/src/theme/freight-brand.ts | 167 ++++++++++++++++-- 6 files changed, 344 insertions(+), 170 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index cc8074850..e626beb58 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -87,7 +87,12 @@ const FreightDashboardHeader = ({ {pageMeta.title} @@ -120,13 +125,6 @@ const FreightDashboardHeader = ({ - - - -
- + {userName} {userEmail && ( diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index a8e0b0aa6..9ffc4753a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -65,25 +65,24 @@ const FreightDashboardLayout = ({ return ( <> - - - - - + - - - + - - {children} - + {children} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css index 17dce66c3..b8d3e5cbe 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css @@ -8,12 +8,7 @@ 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); + border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; @@ -36,8 +31,9 @@ content: ""; position: absolute; inset: 0; - background: - radial-gradient(120px 80px at 24px 18px, rgba(34, 197, 94, 0.08), transparent 70%); + background: radial-gradient(120px 80px at 24px 18px, + rgba(34, 197, 94, 0.08), + transparent 70%); pointer-events: none; } @@ -50,7 +46,7 @@ width: 44px; height: 44px; border-radius: 13px; - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 60%, #15805F 100%); + 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); @@ -72,13 +68,16 @@ .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; } @@ -121,12 +120,10 @@ } .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; + background: linear-gradient(135deg, + rgba(34, 197, 94, 0.12) 0%, + rgba(27, 158, 122, 0.06) 100%); + color: #1b9e7a; font-weight: 600; } @@ -139,7 +136,7 @@ width: 3px; height: 22px; border-radius: 0 4px 4px 0; - background: linear-gradient(180deg, #2DBF95 0%, #1B9E7A 100%); + background: linear-gradient(180deg, #2dbf95 0%, #1b9e7a 100%); } .fsb-item-label { @@ -155,8 +152,6 @@ display: flex; align-items: center; justify-content: center; - width: 31px; - height: 31px; border-radius: 9px; flex-shrink: 0; background: #f1f5f9; @@ -164,13 +159,19 @@ transition: all 160ms ease; } +/* ---- Icon well ---- */ +.fsb-icon svg { + height: 24px; + width: 24px; +} + .fsb-item:hover .fsb-icon { background: #e6ebf1; color: #334155; } .fsb-item[data-active="true"] .fsb-icon { - background: linear-gradient(135deg, #2DBF95 0%, #1B9E7A 100%); + background: linear-gradient(135deg, #2dbf95 0%, #1b9e7a 100%); color: #ffffff; box-shadow: 0 5px 12px -2px rgba(27, 158, 122, 0.45); } @@ -219,9 +220,11 @@ background: transparent; transition: background-color 150ms ease; } + .fsb-group:hover { background-color: #f5f7fa; } + .fsb-group-label { font-size: 11px; font-weight: 700; @@ -229,8 +232,9 @@ text-transform: uppercase; color: #94a3b8; } + .fsb-group[data-active="true"] .fsb-group-label { - color: #1B9E7A; + color: #1b9e7a; } /* child leaf */ @@ -251,12 +255,14 @@ background-color 150ms ease, color 150ms ease; } + .fsb-child:hover { background-color: #f5f7fa; color: #0f172a; } + .fsb-child[data-active="true"] { - color: #1B9E7A; + color: #1b9e7a; font-weight: 600; background-color: rgba(27, 158, 122, 0.08); } @@ -269,11 +275,13 @@ background: #cbd5e1; transition: all 150ms ease; } + .fsb-child:hover .fsb-dot { background: #94a3b8; } + .fsb-child[data-active="true"] .fsb-dot { - background: #1B9E7A; + background: #1b9e7a; box-shadow: 0 0 0 3px rgba(27, 158, 122, 0.16); } @@ -283,36 +291,41 @@ 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%); + 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; + background: #2dbf95; flex-shrink: 0; } + .fsb-pulse::after { content: ""; position: absolute; inset: 0; border-radius: 50%; - background: #2DBF95; + 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; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index a2d685d0f..37a0c2a03 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -11,6 +11,7 @@ import { Box, Stack, Text } from "@mantine/core"; import type { SidebarItem, SidebarSection } from "./types"; import "./FreightSidebar.css"; +import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -176,20 +177,7 @@ const FreightSidebar = ({ return ( - { - if (hasChildren) { - setExpanded((current) => ({ - ...current, - [item.href!]: true, - })); - } - navigateTo(e, item.href!); - }} - > + {item.icon && {item.icon}} {item.label} {hasChildren && ( @@ -212,7 +200,7 @@ const FreightSidebar = ({ /> )} - + {hasChildren && isOpen && (
@@ -224,7 +212,16 @@ const FreightSidebar = ({ }; return ( - +
@@ -233,7 +230,11 @@ const FreightSidebar = ({ EDR Freight @@ -257,20 +258,6 @@ const FreightSidebar = ({
))} - -
-
- - - - All systems operational - - - EDR Platform · v1.0 - - -
-
); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 23f05f3af..e689ef9f8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,6 +1,15 @@ import { type FormEvent, useState } from "react"; import { parsePhoneNumberFromString } from "libphonenumber-js"; -import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react"; +import { + Eye, + EyeOff, + Mail, + Smartphone, + UserRound, + ArrowUpRight, + Globe, + ChevronDown, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; @@ -13,10 +22,25 @@ const loginModes: Array<{ icon: typeof Mail; placeholder: string; }> = [ - { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, - { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, - { value: "username", label: "Username", icon: UserRound, placeholder: "username" }, -]; + { + value: "email", + label: "Email", + icon: Mail, + placeholder: "name@company.com", + }, + { + value: "phone", + label: "Phone", + icon: Smartphone, + placeholder: "09XXXXXXXX", + }, + { + value: "username", + label: "Username", + icon: UserRound, + placeholder: "username", + }, + ]; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/; @@ -43,7 +67,9 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => { } if (!usernamePattern.test(trimmed)) { - throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -."); + throw new Error( + "Username must be 3-32 characters and use letters, numbers, ., _, or -.", + ); } return trimmed; @@ -59,14 +85,24 @@ const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; const LeftPanelDecor = () => ( -
+
{[0, 1, 2, 3, 4, 5].map((ring) => ( - + ))}
@@ -74,12 +110,23 @@ const LeftPanelDecor = () => ( ); const RightPanelDecor = () => ( -
+
@@ -138,13 +189,22 @@ const FormFooter = () => (
© 2026 EDR Freight @@ -176,7 +236,7 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result) + console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; @@ -212,15 +272,20 @@ const LoginPage = () => {
-

Get Started

+

+ Get Started +

- Log in to access the freight backoffice & explore all logistics resources. + Log in to access the freight backoffice & explore all logistics + resources.

- +
- - I agree to EDR Freight{" "} - - Terms & Conditions - - . - - - {error ? (
{error}
) : null} - @@ -318,8 +377,10 @@ const LoginPage = () => {

We sent a verification code to{" "} - {normalizedIdentifier}. Enter it below - to complete sign in. + + {normalizedIdentifier} + + . Enter it below to complete sign in.

@@ -354,7 +415,11 @@ const LoginPage = () => { > Back -
diff --git a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts index 32192dd36..6c1efd3f3 100644 --- a/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts +++ b/apps/edr-freight-web/backoffice/src/theme/freight-brand.ts @@ -1,4 +1,8 @@ -import { createTheme, type MantineColorsTuple } from "@mantine/core"; +import { + colorsTuple, + createTheme, + type MantineColorsTuple, +} from "@mantine/core"; /** EDR Freight primary brand green */ export const FREIGHT_BRAND = "#1B9E7A"; @@ -17,24 +21,155 @@ export const freightBrand = { mutedBorder: "#B7EBDC", } as const; -/** Mantine green scale with #1B9E7A at index 6 (filled buttons, nav active). */ -const freightGreen: MantineColorsTuple = [ - "#E7F8F2", - "#C5EFE1", - "#9CE4CD", - "#6BD6B5", - "#41C8A0", - "#25B58C", - FREIGHT_BRAND, - FREIGHT_BRAND_DARK, - "#105F47", - "#0A3D2E", +const edrGreen: MantineColorsTuple = [ + "#ecfdf5", + "#d1fae5", + "#a7f3d0", + "#6ee7b7", + "#34d399", + "#0EA371", + "#0A8A5F", + "#0A6F4D", + "#065f46", + "#064e3b", +]; + +const neutral: MantineColorsTuple = [ + "#F4F7FA", + "#eef2f7", + "#E6ECF2", + "#d0dae6", + "#b0bfce", + "#8fa0b2", + "#6B7C8E", + "#4b5a6a", + "#10202F", + "#0C1A2B", ]; export const freightMantineTheme = createTheme({ - primaryColor: "green", colors: { - green: freightGreen, + "edr-green": edrGreen, + gray: neutral, + + // Brand surface + text tokens (single-value semantic colors). + // Each generates --mantine-color-{name}-{0..9} CSS variables. + "edr-bg": colorsTuple("#F7FAFC"), + "edr-card": colorsTuple("#FFFFFF"), + "edr-border": colorsTuple("#E6ECF2"), + "edr-divider": colorsTuple("#EEF1F5"), + "edr-text": colorsTuple("#10202F"), + "edr-muted": colorsTuple("#6B7C8E"), + "edr-soft": colorsTuple("#ECF6F1"), + "edr-ink": colorsTuple("#0C1A2B"), + "edr-accent": colorsTuple("#F2A516"), + + // Semantic status shades (from the design system). + "edr-amber-soft": colorsTuple("#FDF3E0"), + "edr-amber-text": colorsTuple("#9A5B00"), + "edr-blue": colorsTuple("#2E5B96"), + "edr-blue-soft": colorsTuple("#E9F0F8"), + "edr-blue-dot": colorsTuple("#3B6FB0"), + "edr-red": colorsTuple("#C0392B"), + "edr-red-soft": colorsTuple("#FBEAE7"), + "edr-slate": colorsTuple("#475569"), + "edr-slate-soft": colorsTuple("#F1F4F7"), + "edr-slate-soft2": colorsTuple("#EEF2F6"), + "edr-step": colorsTuple("#94A3B8"), + "edr-step-idle": colorsTuple("#D5DBE2"), + "edr-conn-idle": colorsTuple("#E6EAEF"), + }, + primaryColor: "edr-green", + primaryShade: { light: 5, dark: 4 }, + + white: "#ffffff", + black: "#10202F", + + fontFamily: + '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + + defaultRadius: "md", + + radius: { + xs: "4px", + sm: "6px", + md: "8px", + lg: "12px", + xl: "18px", + }, + + spacing: { + xs: "8px", + sm: "12px", + md: "16px", + lg: "24px", + xl: "32px", + }, + + fontSizes: { + xs: "12px", + sm: "14px", + md: "16px", + lg: "20px", + xl: "24px", + }, + + lineHeights: { + xs: "1.4", + sm: "1.45", + md: "1.55", + lg: "1.55", + xl: "1.5", + }, + + headings: { + fontFamily: '"Inter", var(--mantine-font-family)', + fontWeight: "700", + sizes: { + h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" }, + h2: { fontSize: "28px", lineHeight: "1.2", fontWeight: "700" }, + h3: { fontSize: "22px", lineHeight: "1.3", fontWeight: "600" }, + h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" }, + h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" }, + h6: { fontSize: "13px", lineHeight: "1.45", fontWeight: "600" }, + }, + }, + + shadows: { + xs: "0 1px 2px rgba(16, 24, 40, 0.04)", + sm: "0 1px 3px rgba(16, 24, 40, 0.06)", + md: "0 4px 12px rgba(16, 24, 40, 0.06)", + }, + + components: { + Card: { + defaultProps: { + radius: "xl", + withBorder: true, + shadow: "none", + padding: "lg", + }, + styles: { + root: { borderColor: "#E6ECF2" }, + }, + }, + Button: { + defaultProps: { radius: "md" }, + styles: { root: { fontWeight: 600 } }, + }, + Badge: { + defaultProps: { radius: "xl", variant: "light" }, + styles: { root: { fontWeight: 600, textTransform: "none" } }, + }, + Paper: { + defaultProps: { radius: "xl", shadow: "none", withBorder: true }, + styles: { root: { borderColor: "#E6ECF2" } }, + }, + Table: { + defaultProps: { verticalSpacing: "sm", horizontalSpacing: "md" }, + }, + Title: { + styles: { root: { letterSpacing: "-0.01em" } }, + }, }, - fontFamily: "'Outfit', var(--font-sans)", }); From e4955c75deb6ded5e6496a810e962e8fb9ff578e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 20 Jun 2026 09:25:23 +0000 Subject: [PATCH 03/11] style: revamp the backaoffice shell --- apps/edr-freight-web/backoffice/index.css | 18 + apps/edr-freight-web/backoffice/src/App.tsx | 54 ++- .../layout/FreightDashboardHeader.css | 126 ------ .../layout/FreightDashboardHeader.tsx | 340 ++++++++-------- .../layout/FreightDashboardLayout.tsx | 110 +++-- .../src/components/layout/FreightSidebar.css | 333 --------------- .../src/components/layout/FreightSidebar.tsx | 382 +++++++++--------- 7 files changed, 439 insertions(+), 924 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css delete mode 100644 apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css index 5dd466b0c..132d7d926 100644 --- a/apps/edr-freight-web/backoffice/index.css +++ b/apps/edr-freight-web/backoffice/index.css @@ -1,6 +1,24 @@ @import "tailwindcss"; @import "@edr/ui-common/theme.css" layer(theme); +/* Bridge the central Mantine theme into Tailwind. freightMantineTheme + (createTheme) is the single source of truth; these just alias its generated + CSS variables so `bg-edr-*`, `text-edr-*`, `border-edr-*` utilities resolve + to the same tokens used by Mantine props. Mirrors edr-freight-web/portal. */ +@theme { + --color-edr-primary: var(--mantine-color-edr-green-5); + --color-edr-primary-dark: var(--mantine-color-edr-green-7); + --color-edr-bg: var(--mantine-color-edr-bg-6); + --color-edr-card: var(--mantine-color-edr-card-6); + --color-edr-border: var(--mantine-color-edr-border-6); + --color-edr-divider: var(--mantine-color-edr-divider-6); + --color-edr-text: var(--mantine-color-edr-text-6); + --color-edr-muted: var(--mantine-color-edr-muted-6); + --color-edr-soft: var(--mantine-color-edr-soft-6); + --color-edr-ink: var(--mantine-color-edr-ink-6); + --color-edr-accent: var(--mantine-color-edr-accent-6); +} + :root { --freight-brand: #1B9E7A; --freight-brand-dark: #15805F; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0c61cc445..4e079b26a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,77 +1,75 @@ -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Boxes, + Container, FileText, LayoutDashboard, LayoutGrid, Network, - Paperclip, + Package, PackageCheck, + PackageOpen, + Paperclip, Send, Settings, SlidersHorizontal, Train, Truck, - Container, - Package, - PackageOpen, Users, Wallet, - //TrainTrack, } from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import OverviewPage from "./pages/dashboard/OverviewPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Main menu", - mutedTitle: true, items: [ { label: "Overview", diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css deleted file mode 100644 index 55410fe26..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.css +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index e626beb58..83c0fac44 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -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,8 +37,15 @@ 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, @@ -38,8 +56,11 @@ const FreightDashboardHeader = ({ onLogout, theme, onToggleTheme, + mobileOpened, + onToggleMobile, }: FreightDashboardHeaderProps) => { const navigate = useNavigate(); + const initials = userInitials ?? (userName @@ -50,194 +71,151 @@ const FreightDashboardHeader = ({ .join("") || "U"); - const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); - const userMenuRef = useRef(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 ( -
- - - {/* - Freight Backoffice */} - - - {pageMeta.title} - - {/* - {pageMeta.subtitle} - */} - + + + {/* Left: burger (mobile) + page title */} + + + + {pageMeta.title} + + - - {enableThemeToggle && ( - + {/* Search pill */} + - + + + Search bookings, trains… + + + + + + + - )} - - - + + + + + + + - - - + {enableThemeToggle && ( + + + {theme === "dark" ? ( + + ) : ( + + )} + + + )} -
- - setIsUserMenuOpen(true)} - onClose={() => setIsUserMenuOpen(false)} - > - -
-
-
{initials}
-
- - - {userName} - - - {userEmail ?? "Administrator"} - - - -
-
- - - -
-
{initials}
-
- - + {/* Avatar pill */} + + + + + + {initials} + + + + {userName} - {userEmail && ( - - {userEmail} - - )} - - - - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile"); - }} - > - Profile - - } - onClick={() => { - setIsUserMenuOpen(false); - navigate("/dashboard/profile#signature"); - }} - > - My signature - - } - color="red" - onClick={() => { - setIsUserMenuOpen(false); - onLogout?.(); - }} - > - Logout - - - + + {userEmail ?? "Administrator"} + +
+ + + - {headerRight} + + + + {userName} + + {userEmail && ( + + {userEmail} + + )} + + + } + onClick={() => navigate("/dashboard/profile")} + > + Profile + + } + onClick={() => navigate("/dashboard/profile#signature")} + > + My signature + + + } + color="red" + onClick={() => onLogout?.()} + > + Logout + + +
+ + {headerRight} + -
+ ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx index 9ffc4753a..cd930d29b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardLayout.tsx @@ -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(() => enableThemeToggle ? getInitialTheme() : "light", ); @@ -52,71 +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 ( - <> - + + + + + + + {/* Internal scroll keeps the fixed-viewport model the dashboard pages + assume (sidebar + header stay put, content scrolls beneath). */} - - - - - - - {children} - - + {children} - - + + ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css deleted file mode 100644 index b8d3e5cbe..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ /dev/null @@ -1,333 +0,0 @@ -/* ============================================================ - EDR Freight — Sidebar styles - Polished, professional navigation surface. - ============================================================ */ - -.fsb-aside { - height: 100%; - max-height: 100%; - width: 280px; - flex-shrink: 0; - border-radius: 12px; - 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; - border-radius: 9px; - flex-shrink: 0; - background: #f1f5f9; - color: #64748b; - transition: all 160ms ease; -} - -/* ---- Icon well ---- */ -.fsb-icon svg { - height: 24px; - width: 24px; -} - -.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; - } -} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 37a0c2a03..59efe49a7 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -1,264 +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"; -import { Link } from "react-router-dom"; 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 = {}; - - 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>(defaultExpanded); + }, [sections, isHrefActive, branchActive]); + const [openMap, setOpenMap] = useState(defaultOpen); useEffect(() => { - setExpanded((current) => ({ ...defaultExpanded, ...current })); - }, [defaultExpanded]); + setOpenMap((current) => ({ ...defaultOpen, ...current })); + }, [defaultOpen]); - const navigateTo = (event: MouseEvent, 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 ( -
- - {isOpen && ( -
- {renderNavBranch(child.children!, depth + 1, key)} -
+ className="flex cursor-pointer items-center" + > + + + } + > + {item.children!.map((child, i) => + renderItem(child, itemKey(key, child, i)), )} -
+ ); } - if (!child.href) return null; - - const childActive = isHrefActive(child.href); + if (!item.href) return null; + const active = isHrefActive(item.href); return ( - navigateTo(e, child.href!)} - > - - {child.label} - + 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 ( - - - {item.icon && {item.icon}} - {item.label} - {hasChildren && ( - - )} - - - {hasChildren && isOpen && ( -
- {renderNavBranch(item.children!, 0, item.href)} -
- )} -
- ); - }; - - return ( - -
-
- -
- - - EDR Freight - + const renderedSections = useMemo( + () => + sections.map((section) => ( + - Backoffice Console + {section.title} - -
+ + {section.items.map((item, i) => + renderItem(item, itemKey(section.title, item, i)), + )} + +
+ )), + [renderItem, sections], + ); - - + return ( + + {/* Brand — aligns with the 64px header for a continuous top edge */} + + + EDR Freight + + + EDR Freight + + + Backoffice Console + + + + {onClose && ( + + + + )} + + + {/* Nav */} + + {renderedSections} + + ); }; From 138ac5b47db61c9a7693c875620916be866a7bed Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 20 Jun 2026 10:20:38 +0000 Subject: [PATCH 04/11] style:wip dashboard ui revamp --- .../layout/FreightDashboardHeader.tsx | 19 +- .../src/components/layout/route-meta.ts | 21 + .../src/components/page/KpiStrip.tsx | 94 ++++ .../src/components/page/PageContainer.tsx | 26 + .../src/components/page/PageHeader.tsx | 78 +++ .../backoffice/src/components/page/index.ts | 6 + .../DropdownSettingsPage.tsx | 483 +++++++----------- .../src/pages/payments/PaymentsPage.tsx | 377 ++++++-------- .../warehouses/WarehouseDashboardPage.tsx | 88 ++-- .../pages/warehouses/WarehouseDetailPage.tsx | 124 ++--- 10 files changed, 647 insertions(+), 669 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/page/index.ts diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 83c0fac44..8a45c7cf0 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -47,7 +47,6 @@ const ISLAND = "flex size-9 shrink-0 items-center justify-center rounded-full border border-edr-border bg-white text-edr-text transition-colors hover:bg-[#F1F4F7]"; const FreightDashboardHeader = ({ - pageMeta, headerRight, enableThemeToggle = false, userName = "User", @@ -86,7 +85,8 @@ const FreightDashboardHeader = ({ }} > - {/* Left: burger (mobile) + page title */} + {/* Left: burger (mobile) + search — the search now occupies the slot + the page title used to hold; each page owns its own title. */} - - {pageMeta.title} - - - - {/* Right: search + actions + avatar */} - - {/* Search pill */} Search bookings, trains… + + {/* Right: actions + avatar */} + diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 879292143..0c03b21b1 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -50,6 +50,27 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "View booking payment transactions", }, }, + { + prefix: "/dashboard/warehouse-dashboard", + meta: { + title: "Warehouse Dashboard", + subtitle: "Live overview of warehouse capacity and inventory lifecycle", + }, + }, + { + prefix: "/dashboard/warehouses/", + meta: { + title: "Warehouse detail", + subtitle: "Yards, zones, and inventory for this warehouse", + }, + }, + { + prefix: "/dashboard/warehouses", + meta: { + title: "Warehouses", + subtitle: "Manage warehouses, yards and zones", + }, + }, { prefix: "/dashboard/operations/train-scheduling-v2/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx new file mode 100644 index 000000000..81b75966c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -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 ( + +
+ {cells.map((item, index) => { + const Icon = item.icon; + const color = item.color ?? "edr-green"; + return ( +
0 && + "border-t border-edr-border sm:border-l sm:border-t-0", + )} + > + {Icon ? ( +
+ +
+ ) : null} + +
+ {loading ? ( + + ) : ( + + {item.value} + + )} + + {item.label} + {item.hint ? ` · ${item.hint}` : ""} + +
+
+ ); + })} +
+
+ ); +} + +export default KpiStrip; diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx new file mode 100644 index 000000000..15c599294 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/PageContainer.tsx @@ -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 ( + + {children} + + ); +} + +export default PageContainer; diff --git a/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx new file mode 100644 index 000000000..087567585 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/PageHeader.tsx @@ -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 ( + + {breadcrumbs?.length ? : null} + + + + {backTo ? ( + navigate(backTo)} + aria-label="Go back" + > + + + ) : null} + +
+ + + {title} + + {meta} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+
+ + {action ? ( + + {action} + + ) : null} +
+
+ ); +} + +export default PageHeader; diff --git a/apps/edr-freight-web/backoffice/src/components/page/index.ts b/apps/edr-freight-web/backoffice/src/components/page/index.ts new file mode 100644 index 000000000..8d455cf10 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/page/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx index 7edaeb7b1..d2689c750 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/DropdownSettingsPage.tsx @@ -1,12 +1,10 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { - AlertCircle, Boxes, CheckCircle2, Eye, Filter, ListOrdered, - Loader2, MoreHorizontal, Pencil, Plus, @@ -16,8 +14,20 @@ import { Sparkles, Trash2, } from "lucide-react"; +import { + ActionIcon, + Badge, + Button, + Card, + Code, + Group, + Menu, + Text, + TextInput, + ThemeIcon, +} from "@mantine/core"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; @@ -30,18 +40,6 @@ import { DataTableFooter, type ColumnDef, usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, } from "@edr/ui-common"; type ActiveDialog = "edit" | "options" | "delete"; @@ -50,40 +48,17 @@ export default function DropdownSettingsPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); const [activeDialog, setActiveDialog] = useState(null); const [activeSetting, setActiveSetting] = useState( null, ); const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => { - // Defer past the DropdownMenu's close cycle. Radix's modal lock can leave - // `pointer-events: none` on when a menu closes and a dialog opens - // in the same frame — wait two RAFs and then explicitly reset the body - // style so the dialog interior is interactive. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - document.body.style.pointerEvents = ""; - setActiveSetting(setting); - setActiveDialog(dialog); - }); - }); + setActiveSetting(setting); + setActiveDialog(dialog); }; - const closeDialog = () => { - setActiveDialog(null); - // Keep activeSetting briefly so dialog content doesn't flash empty during - // the close animation; cleared on next open. - }; - - // Belt-and-suspenders for the Radix pointer-events leak: any time the active - // dialog changes, schedule a body-style cleanup after the next paint. - useEffect(() => { - const id = requestAnimationFrame(() => { - if (document.body.style.pointerEvents === "none") { - document.body.style.pointerEvents = ""; - } - }); - return () => cancelAnimationFrame(id); - }, [activeDialog]); + const closeDialog = () => setActiveDialog(null); const { data, isLoading, isError, error } = useQuery( api.dropdownSettings.list.queryOptions(), @@ -138,41 +113,38 @@ export default function DropdownSettingsPage() { cell: ({ row }) => { const s = row.original; return ( -
-
- -
-
-

{s.label}

-

+ + + + +

+ + {s.label} + + {s.description ?? "No description"} -

+
-
+ ); }, }, { id: "code", header: "Code", - cell: ({ row }) => ( - - {row.original.code} - - ), + cell: ({ row }) => {row.original.code}, }, { id: "options", header: "Options", - cell: ({ row }) => { - const s = row.original; - return ( -
- - {s.children?.length ?? 0} -
- ); - }, + cell: ({ row }) => ( + + + + {row.original.children?.length ?? 0} + + + ), }, { id: "behavior", @@ -180,15 +152,21 @@ export default function DropdownSettingsPage() { cell: ({ row }) => { const s = row.original; return ( -
- {s.multiple ? ( - - ) : ( - - )} - {s.meta?.searchable ? : null} - {s.meta?.clearable ? : null} -
+ + + {s.multiple ? "Multi" : "Single"} + + {s.meta?.searchable ? ( + + Searchable + + ) : null} + {s.meta?.clearable ? ( + + Clearable + + ) : null} + ); }, }, @@ -196,24 +174,27 @@ export default function DropdownSettingsPage() { id: "permissions", header: "Permissions", cell: ({ row }) => { - const s = row.original; - const perms = s.meta?.permissions ?? []; + const perms = row.original.meta?.permissions ?? []; + if (perms.length === 0) { + return ( + + — + + ); + } return ( -
- {perms.length === 0 ? ( - - ) : ( - perms.map((p) => ( - - - {p} - - )) - )} -
+ + {perms.map((p) => ( + } + > + {p} + + ))} + ); }, }, @@ -223,178 +204,136 @@ export default function DropdownSettingsPage() { cell: ({ row }) => { const setting = row.original; return ( -
e.stopPropagation()} - > - - - - - - - - View - - openDialogFor("options", setting)} + e.stopPropagation()}> + + + + + + + + }>View + } + onClick={() => openDialogFor("options", setting)} > - Options - - - openDialogFor("edit", setting)} + + + } + onClick={() => openDialogFor("edit", setting)} > - Edit - - - openDialogFor("delete", setting)} - variant="destructive" + + + } + color="red" + onClick={() => openDialogFor("delete", setting)} > - Delete - - - -
+ + + + ); }, }, ]; return ( -
-
- - - -
-

- Dropdown Settings -

-

- Manage every dynamic dropdown across the platform — labels, - options, ordering, and permissions. -

-
- -
-
- - { - setQuery(e.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search by code, label, description..." - className="pl-8!" - /> -
- - - - -
-
- -
- } - /> - } - /> - } - /> - } - /> -
- - {isError ? ( - - - - Failed to load dropdown settings.{" "} - {error instanceof Error ? error.message : "Unknown error."} - - - ) : null} - - - -
- Registered Dropdowns - - Every dynamic dropdown the platform reads from. - -
- - -
+ + } + /> - - {isLoading ? ( -
- - Loading dropdown settings… -
- ) : ( - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - )} -
-
-
+ - {/* Controlled dialogs — hoisted out of the DropdownMenu so they can open - reliably after a menu item is selected. */} + + +
+ + Registered Dropdowns + + + Every dynamic dropdown the platform reads from. + +
+ +
+ + +
+ + {/* Create — fully controlled, no shadcn trigger child. */} + + + {/* Controlled row-action dialogs. */} {activeSetting ? ( <> ) : null} -
- ); -} - -function StatCard({ - label, - value, - icon, -}: { - label: string; - value: number; - icon: React.ReactNode; -}) { - return ( - - -
-

{label}

-

{value}

-
-
- {icon} -
-
-
- ); -} - -function BehaviorChip({ - label, - muted = false, -}: { - label: string; - muted?: boolean; -}) { - return ( - - {label} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index af92d38fd..798077020 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -1,18 +1,14 @@ -import { useMemo, useState } from "react"; import { ActionIcon, - Box, Card, - Container, Group, - Paper, + Badge as MantineBadge, Select, Stack, Tabs, Text, TextInput, } from "@mantine/core"; -import { Badge as MantineBadge } from "@mantine/core"; import { CheckCircle2, CircleDollarSign, @@ -22,23 +18,18 @@ import { Search, X, XCircle, - type LucideIcon, } from "lucide-react"; +import { useMemo, useState } from "react"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import "@/components/overview/overview.css"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; -import type { - PaymentMethod, - PaymentRow, -} from "@/services/payments.service"; -import { cn } from "@/lib/utils"; +import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { Badge, DataTable, DataTableFooter, - type ColumnDef, usePagination, + type ColumnDef, } from "@edr/ui-common"; const STATUS_TABS = [ @@ -75,57 +66,6 @@ const STATUS_COLORS: Record = { refunded: "indigo", }; -function StatCard({ - icon: Icon, - label, - value, - accent, -}: { - icon: LucideIcon; - label: string; - value: string | number; - accent: string; -}) { - return ( - - - - - - - - {value} - - - {label} - - - - - ); -} - function formatAmount(amount: number, currency: string): string { return `${currency} ${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2, @@ -172,8 +112,6 @@ export default function PaymentsPage() { const total = data?.total ?? 0; const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0)); - const tabCounts: Record = { all: summary === undefined @@ -248,177 +186,152 @@ export default function PaymentsPage() { ]; return ( -
- - + + - - - + + { + setStatusTab((value as StatusTabKey) ?? "all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + variant="pills" + color="edr-green" + keepMounted={false} + > + + {STATUS_TABS.map((t) => { + const isActive = statusTab === t.key; + const count = tabCounts[t.key]; + const Icon = t.icon; + return ( + } + rightSection={ + count !== undefined ? ( + + {count} + + ) : undefined + } + > + {t.label} + + ); + })} + + + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + rightSection={ + query && ( + setQuery("")} + > + + + ) } - accent="teal" + style={{ flex: 1, minWidth: "200px" }} /> - - - - { + setMethod(value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + style={{ minWidth: 180 }} /> + + {total} record{total !== 1 ? "s" : ""} + - { - setStatusTab((value as StatusTabKey) ?? "all"); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} - variant="pills" - color="green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - {STATUS_TABS.map((t) => { - const isActive = statusTab === t.key; - const count = tabCounts[t.key]; - const Icon = t.icon; - return ( - } - rightSection={ - count !== undefined ? ( - - {count} - - ) : undefined - } - > - {t.label} - - ); - })} - - - - - - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) - } - w={200} - /> - setDraft((f) => ({ ...f, zoneId: value ?? undefined }))} - w={180} - /> - + setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) + } + w={200} + /> + setDraft((f) => ({ ...f, zoneId: value ?? undefined }))} + w={180} + /> + - setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) - } - w={220} - /> - setFilter((f) => ({ ...f, zoneId: value ?? undefined }))} - w={200} - /> - + setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) + } + w={220} + /> + setFilter((f) => ({ ...f, zoneId: value ?? undefined }))} + w={200} + /> + setQuery(e.target.value)} - placeholder="Search by code, label, or file key..." - className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" - /> -
- - - - -
-
+ + + + + + ); + }, + }, + ]; + }, [deleteMutation]); - {/* Stats */} -
- } - /> - } - /> - } - /> - } - /> -
+ const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; - {/* Table */} -
-
-
-

- Registered File Upload Groups -

-

- Every group a form can reference by code. -

-
+ return ( + + + + + } + /> - -
+ -
- - - - - - - - - - - - + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + aria-label="Clear search" + > + + + ) : null + } + style={{ maxWidth: 420 }} + /> + - - {isLoading ? ( - - - - ) : isError ? ( - - - - ) : filtered.length === 0 ? ( - - - - ) : ( - filtered.map((setting) => { - const required = setting.fields.filter( - (f: any) => f.isRequired, - ).length; - const multi = setting.fields.filter( - (f: any) => f.isMultiple, - ).length; - const maxSize = Math.max( - 0, - ...setting.fields.map((f) => f.maxSizeMb), - ); + + void refetch(), + } + : undefined + } + emptyMessage={ + query.trim() + ? "No file upload settings match your search." + : 'No file upload settings yet. Click "New setting" to add one.' + } + containerClassName="border-0 shadow-none bg-transparent min-w-[920px]" + /> + + + - return ( - - + + + ); +} - +/** + * Static reference: how `min_files` / `max_files` are derived from the + * Required × Multiple toggles. Documentation aid for whoever wires uploaders. + */ +function BehaviorReferenceCard() { + const rows: { required: boolean; multiple: boolean; min: string; max: string }[] = + [ + { required: false, multiple: false, min: "0", max: "1" }, + { required: true, multiple: false, min: "1", max: "1" }, + { required: false, multiple: true, min: "0", max: "field.maxFiles" }, + { required: true, multiple: true, min: "1", max: "field.maxFiles" }, + ]; - + return ( + + + Required × Multiple behavior + + Min and max file counts are derived from these two toggles. The + "Max files" you set on a field is only used when{" "} + + Multiple + {" "} + is on. + - +
SettingCodeEntityFieldsRequired / MultiMax SizeActions
- -

- Loading file upload settings… -

-
- -

- Failed to load settings.{" "} - {error instanceof Error ? error.message : "Unknown error."} -

-
- {fileUploadSettings.length === 0 - ? "No file upload settings yet. Click \"New Setting\" to add one." - : "No file upload settings match your search."} -
-
-
- -
-
-

- {setting.label} -

-

- {setting.description ?? "No description"} -

-
-
-
- - {setting.code} - - - - {setting.entity ?? "—"} - - -
- - - {setting.fields.length} - -
-
+ + + Required + Multiple + min_files + max_files + + + + {rows.map((r) => ( + + + + {r.required ? "Required" : "Optional"} + + + + + {r.multiple ? "Multiple" : "Single"} + + + + {r.min} + + + {r.max} + + + ))} + +
- -
- {required} required - {multi} multi -
- - - -
- - {maxSize ? `${maxSize} MB` : "—"} -
- - - -
- - - - - - - - - - deleteMutation.mutate(setting.id) - } - > - - -
- - - ); - }) - )} - - -
-
- - {/* Behavior reference card */} -
-

- Required × Multiple behavior -

-

- Min and max file counts are derived from these two toggles. The - "Max Files" you set on a field is only used when{" "} - Multiple is on. -

-
- - - - - - - - - - - - - - - -
RequiredMultiplemin_filesmax_files
-
-

- Helpers getMinFiles and{" "} - getEffectiveMaxFiles live in{" "} - @/types/fileUploadSettings — use - them when wiring real uploaders. Example: a field with{" "} - isRequired=false,{" "} - isMultiple=true,{" "} - maxFiles=5 gives{" "} - {getMinFiles({ - id: "demo", - fileKey: "demo", - fileLabel: "demo", + + Helpers getMinFiles and getEffectiveMaxFiles{" "} + live in @/types/fileUploadSettings — use them when wiring + real uploaders. Example: a field with isRequired=false,{" "} + isMultiple=true, maxFiles=5 gives min{" "} + + {getMinFiles({ isRequired: false, - isMultiple: true, - maxFiles: 5, - allowedExtensions: [], - maxSizeMb: 1, - })} - …5. -

-
-
-
- ); -} - -function BehaviorRow({ - required, - multiple, - min, - max, -}: { - required: boolean; - multiple: boolean; - min: string; - max: string; -}) { - return ( - - - {required ? "Required" : "Optional"} - - - {multiple ? "Multiple" : "Single"} - - {min} - {max} - - ); -} - -function Chip({ - children, - muted = false, -}: { - children: React.ReactNode; - muted?: boolean; -}) { - return ( - - {children} - - ); -} - -function StatCard({ - title, - value, - icon, -}: { - title: string; - value: string; - icon: React.ReactNode; -}) { - return ( -
-
-
-

{title}

-

{value}

-
-
- {icon} -
-
-
+ })} +
{" "} + …5. + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index dfaf52fac..6292a1764 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,25 +1,35 @@ -import { useEffect, useMemo, useState } from "react"; -import { Navigate, useLocation } from "react-router-dom"; import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { + Archive, + Circle, + CircleCheck, + CircleSlash, + Layers, + Link2, + Plus, + Wrench, + type LucideIcon, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Navigate, useLocation } from "react-router-dom"; -import { PageContainer } from "@/components/page"; import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; import { useCargoTypes } from "@/hooks/use-cargo-types"; import { useContainerTypes } from "@/hooks/use-container-types"; -import { useWagonTypes } from "@/hooks/use-wagon-types"; -import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet"; -import { useContainers } from "@/hooks/useContainers"; import { useToast } from "@/hooks/use-toast"; +import { useWagonTypes } from "@/hooks/use-wagon-types"; +import { useContainers } from "@/hooks/useContainers"; import { useRouteYards } from "@/hooks/useRoutes"; import { useWagons } from "@/hooks/useWagons"; -import type { FleetListFilters } from "@/services/fleet/fleet.service"; import { FLEET_SELECT_NONE, getFleetResource, @@ -27,11 +37,27 @@ import { type FleetFormFieldDef, type FleetResourceSlug, } from "@/pages/fleet/config/resources"; -import type { FleetRecord } from "@/services/fleet/fleet.service"; +import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; +const FLEET_STATUS_META: Record< + string, + { label: string; icon: LucideIcon; color: string } +> = { + AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" }, + ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" }, + MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" }, + OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" }, + RETIRED: { label: "Retired", icon: Archive, color: "gray" }, +}; + +const humanizeStatus = (status: string) => { + const text = status.replace(/_/g, " ").toLowerCase(); + return text.charAt(0).toUpperCase() + text.slice(1); +}; + const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; @@ -250,6 +276,39 @@ const FleetResourcePage = () => { const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const kpiItems = useMemo(() => { + const items = [ + { + label: `Total ${config?.label.toLowerCase() ?? ""}`, + value: allRows.length, + icon: Layers, + color: "edr-green", + }, + ]; + if (hasStatusColumn) { + const counts = new Map(); + for (const row of allRows) { + const status = String( + (row as unknown as Record).status ?? "", + ); + if (status) counts.set(status, (counts.get(status) ?? 0) + 1); + } + const top = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 4); + for (const [status, count] of top) { + const meta = FLEET_STATUS_META[status]; + items.push({ + label: meta?.label ?? humanizeStatus(status), + value: count, + icon: meta?.icon ?? Circle, + color: meta?.color ?? "gray", + }); + } + } + return items.slice(0, 5); + }, [allRows, hasStatusColumn, config?.label]); + if (!config) { return ; } @@ -293,6 +352,24 @@ const FleetResourcePage = () => { return ( + } + onClick={() => { + setEditing(null); + setFormOpen(true); + }} + > + {config.addLabel} + + } + /> + + + @@ -301,11 +378,6 @@ const FleetResourcePage = () => { onSearchChange={setSearch} searchPlaceholder={config.searchPlaceholder} showSearch={config.supportsSearch} - addLabel={config.addLabel} - onAdd={() => { - setEditing(null); - setFormOpen(true); - }} viewMode={viewMode} onViewModeChange={setViewMode} filters={ @@ -316,7 +388,6 @@ const FleetResourcePage = () => { key={filter.key} size="sm" radius="lg" - label={filter.label} value={filter.value} onChange={(v) => { if (!v) return; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 72ef9c965..543ae26db 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -1,5 +1,5 @@ import { FormEvent, useMemo, useState } from "react"; -import { Edit, Eye, Trash2 } from "lucide-react"; +import { Ban, CircleCheck, Edit, Eye, Plus, Route as RouteIcon, Trash2 } from "lucide-react"; import type { ColumnDef } from "@edr/ui-common"; import { ActionIcon, @@ -17,7 +17,7 @@ import { Tooltip, } from "@mantine/core"; -import { PageContainer } from "@/components/page"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; @@ -102,6 +102,9 @@ export default function RoutesPage() { return filteredRoutes.slice(start, start + pagination.pageSize); }, [filteredRoutes, pagination.pageIndex, pagination.pageSize]); + const allRoutes = routesQuery.data ?? []; + const activeCount = allRoutes.filter((route) => route.isActive).length; + const yardOptions = useMemo( () => (yardsQuery.data ?? []).map((yard) => ({ @@ -281,6 +284,30 @@ export default function RoutesPage() { return ( + } onClick={openCreate}> + Add route + + } + /> + + + @@ -288,8 +315,6 @@ export default function RoutesPage() { search={search} onSearchChange={setSearch} searchPlaceholder="Search routes…" - addLabel="Add Route" - onAdd={openCreate} viewMode={viewMode} onViewModeChange={setViewMode} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 0c6c4b284..e5a490927 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Box, Card, Group, Badge as MantineBadge, @@ -268,49 +269,51 @@ export default function PaymentsPage() { - - - - } - value={query} - onChange={(e) => { - setQuery(e.target.value); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - /> - { + setMethod(value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + style={{ minWidth: 180 }} + /> + + {total} record{total !== 1 ? "s" : ""} + + + -
+ -
+
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index f03fb199e..783c0ffd4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -3,9 +3,10 @@ import { Navigate, useLocation, useParams } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; import { canAccessRuleEngineResource } from "@/lib/permissions"; import type { ColumnDef } from "@tanstack/react-table"; -import { Loader2 } from "lucide-react"; -import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core"; +import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core"; +import { Plus } from "lucide-react"; +import { PageContainer, PageHeader } from "@/components/page"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; @@ -325,31 +326,44 @@ const RuleEngineResourcePage = () => { }; const itemLabel = config.label.toLowerCase(); + const addLabel = `Add ${config.label.replace(/s$/, "")}`; return ( - - - - { - setSearch(v); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - } - : undefined - } - showSearch={Boolean(config.supportsSearch)} - searchPlaceholder={config.searchPlaceholder} - onAdd={canManage ? openCreate : undefined} - addLabel={`Add ${config.label.replace(/s$/, "")}`} - onManageOrder={ - canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined - } - viewMode={viewMode} - onViewModeChange={setViewMode} - /> + + } onClick={openCreate}> + {addLabel} + + ) : undefined + } + /> + + + + + { + setSearch(v); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + } + : undefined + } + showSearch={Boolean(config.supportsSearch)} + searchPlaceholder={config.searchPlaceholder} + onManageOrder={ + canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined + } + viewMode={viewMode} + onViewModeChange={setViewMode} + /> + {viewMode === "table" ? ( { state: { pagination }, onPaginationChange: setPagination, }} - containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border" - footerClassName="border-t border-border bg-card" + containerClassName="border-0 shadow-none bg-transparent" footer={({ table, pagination: footerPagination }) => ( { @@ -498,7 +510,7 @@ const RuleEngineResourcePage = () => { {chainLoading ? ( - + ) : ( <> @@ -524,7 +536,7 @@ const RuleEngineResourcePage = () => { )} - + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 5586357c1..d389009aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -1,6 +1,3 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { isAxiosError } from "axios"; import type { ColumnDef } from "@edr/ui-common"; import { ActionIcon, @@ -17,6 +14,7 @@ import { TextInput, ThemeIcon, } from "@mantine/core"; +import { isAxiosError } from "axios"; import { ArrowRight, Ban, @@ -28,24 +26,26 @@ import { Train, Weight, } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; -import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { RouteCorridor, StatusPill, } from "@/components/trainScheduling/scheduleVisuals"; -import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; -import { useRoutes } from "@/hooks/useRoutes"; import { useAvailableLocomotives, useScheduleList, useScheduleMutations, } from "@/hooks/trainScheduling/useTrainScheduling"; import { useToast } from "@/hooks/use-toast"; +import { useRoutes } from "@/hooks/useRoutes"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; @@ -262,7 +262,7 @@ export default function TrainScheduleV2ListPage() { }, { id: "actions", - header: "Actions", + size:32, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { const schedule = row.original;