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 46c8e73f3..11e4aa8eb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,78 +1,77 @@ -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 TrainDetailPage from "./pages/trains/TrainDetailPage"; import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; 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", @@ -213,13 +212,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, - { - title: "Administration", - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , permission: FREIGHT_PERMS.admin, }, { @@ -312,216 +311,216 @@ const App = () => { return ; } - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - }> - } /> - } /> - - } /> - - - - } - /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } /> - } /> - - - } /> - - ); -}; + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx index 25b3816b7..a38e2f0e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -68,7 +68,7 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps icon={ShieldCheck} title="Approval chain" extra={ - + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} } @@ -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 && ( - diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx index eb00b8b71..5caa10fd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingLifecycleStepper.tsx @@ -68,7 +68,7 @@ export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx index 7d0535bd4..6f102e5c6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCard.tsx @@ -29,7 +29,7 @@ export function BookingPaymentCard({ @@ -70,7 +64,7 @@ export function BookingRequestHero({ - diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 2a594a645..1922cb92b 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -35,7 +35,7 @@ const FleetRecordActions = ({ {showDetail ? ( + + + Search bookings, trains… + + + + + {/* Right: actions + avatar */} + + + + + - )} - - - + + + + + + + - - - + {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 a8e0b0aa6..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,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 ( - <> - - - + - + + + + {/* 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 17dce66c3..000000000 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.css +++ /dev/null @@ -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; - } -} 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 344e80c18..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,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 = {}; - - 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 ( - - { - if (hasChildren) { - setExpanded((current) => ({ - ...current, - [item.href!]: true, - })); - } - navigateTo(e, item.href!); - }} - > - {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} - -
- - - -
-
- - - - All systems operational - - - EDR Platform · v1.0 - + + {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} + + ); }; 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/overview/OverviewKpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx index 04740585b..f2ce3b21b 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiStrip.tsx @@ -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[] = [ - "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 = { + 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 ( - - {title && ( - - {title} - - )} - - {items.map((item, index) => ( - 0 ? toNumber(item.value) / max : 0), - }} - /> - ))} - - - ); + return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx index 3c2e79f75..529981add 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx @@ -47,11 +47,11 @@ export function OverviewPageHeader({ data={RANGE_OPTIONS} size="sm" radius="lg" - color="green" + color="edr-green" /> - + diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index b39d5def8..2438b0368 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -78,7 +78,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { {isFetching && (
- +
)} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css index 343e622d7..86b468df3 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.css +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -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; 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..6c8167a33 --- /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/components/ruleEngine/ManageRuleEngineOrderDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx index df5da1b17..ced8eb2b1 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ManageRuleEngineOrderDialog.tsx @@ -333,7 +333,7 @@ const ManageRuleEngineOrderDialog = ({ Cancel -
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 1d7a8316d..ce4b81c05 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -15,7 +15,7 @@ const INVOICE_STATUS_COLOR: Record = { 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 -
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx index 8a282957a..366a3950c 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInquiryTable.tsx @@ -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 ( - - No matching items. Adjust your search to locate cargo, containers or goods. +const columns: ColumnDef[] = [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => ( + + {row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)} - ); - } + ), + }, + { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' }, + { id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) }, + { + id: 'warehouse', + header: 'Warehouse', + cell: ({ row }) => ( + + {row.original.warehouse?.name ?? '—'} + {row.original.warehouse?.code && ( + + {row.original.warehouse.code} + + )} + + ), + }, + { 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 }) => , + }, + { 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 }) => {formatDate(row.original.arrivedAt)}, + }, + { + id: 'ready', + header: 'Ready', + cell: ({ row }) => {formatDate(row.original.readyForLoadingAt)}, + }, +]; +export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) { return ( - - - - - Booking - Customer - Item - Warehouse - Yard - Zone - Status - Qty - Weight - Arrived - Ready - - - - {results.map((result) => ( - - - - {result.bookingNumber ?? result.bookingId.slice(0, 8)} - - - {result.customerName ?? '—'} - {itemDescriptor(result)} - - - {result.warehouse?.name ?? '—'} - {result.warehouse?.code && ( - - {result.warehouse.code} - - )} - - - {result.yard?.name ?? '—'} - {result.zone?.name ?? '—'} - - - - {formatNumber(result.quantity)} - {formatNumber(result.weight)} - - {formatDate(result.arrivedAt)} - - - {formatDate(result.readyForLoadingAt)} - - - ))} - -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index d4ce1a1d6..a1a00d2e9 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,5 +1,7 @@ -import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; -import { ArrowRightLeft, ClipboardList, Coins, History, MapPin } from 'lucide-react'; +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'; @@ -36,7 +38,7 @@ const actionColor: Record = { reserve: 'grape', 'ready-for-loading': 'cyan', load: 'teal', - dispatch: 'green', + dispatch: 'edr-green', 'ready-for-pickup': 'orange', release: 'yellow', deliver: 'green', @@ -57,166 +59,117 @@ export function WarehouseInventoryTable({ allSelected, someSelected, }: WarehouseInventoryTableProps) { - const selectable = Boolean(onToggleSelect); - if (items.length === 0) { - return ( - - No inventory items found. - - ); - } + const columns = useMemo[]>( + () => [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => + row.original.bookingId ? ( + + + {row.original.bookingId.slice(0, 8)}… + + + ) : ( + + — + + ), + }, + { + 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 ( + + {kind.label} + + ); + }, + }, + { 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 }) => , + }, + { + id: 'arrived', + header: 'Arrived', + cell: ({ row }) => {formatDate(row.original.arrivedAt)}, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => { + const item = row.original; + const busy = busyId === item.id; + const nextAction = INVENTORY_NEXT_ACTION[item.status]; + return ( + e.stopPropagation()}> + {nextAction && ( + + )} + {item.status !== 'DISPATCHED' && ( + + onMove(item)}> + + + + )} + {onInspect && ( + + onInspect(item)}> + + + + )} + {onFeePreview && ( + + onFeePreview(item)}> + + + + )} + + onHistory(item)}> + + + + + ); + }, + }, + ], + [busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview], + ); return ( - - - - - {selectable && ( - - - - )} - Booking - Facility - Warehouse - Yard - Zone - Item - Qty - Weight - Status - Arrived - Actions - - - - {items.map((item) => { - const kind = itemKind(item); - const busy = busyId === item.id; - const nextAction = getNextInventoryAction(item); - return ( - - {selectable && ( - - onToggleSelect?.(item.id)} - /> - - )} - - {item.bookingId ? ( - - - {item.bookingId.slice(0, 8)}… - - - ) : ( - - — - - )} - - {item.warehouse?.facility?.name ?? '—'} - {item.warehouse?.code ?? '—'} - {item.yard?.code ?? '—'} - {item.zone?.code ?? '—'} - - - {kind.label} - - - {formatNumber(item.quantity)} - {formatNumber(item.weight)} - - - - - {formatDate(item.arrivedAt)} - - - - {nextAction && ( - - )} - {/* Batch 10 — a PICKUP_READY import item can also be stored or dispatched, - kept separate from the customer-pickup (release/deliver) next action. */} - {item.status === 'READY_FOR_PICKUP' && ( - <> - - - - )} - {item.status !== 'DISPATCHED' && ( - - onMove(item)}> - - - - )} - {onInspect && ( - - onInspect(item)}> - - - - )} - {onFeePreview && ( - - onFeePreview(item)}> - - - - )} - {onLastMile && item.booking?.lastMileDeliveryAddress && ( - - onLastMile(item)}> - - - - )} - - onHistory(item)}> - - - - - - - ); - })} - -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index fbd3a94c1..80b85f693 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -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 ( - - No warehouses found. - - ); - } + const columns: ColumnDef[] = [ + { + id: 'code', + header: 'Code', + cell: ({ row }) => ( + onView(row.original)} + > + {row.original.code} + + ), + }, + { 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 ? ( + + {name} + + ) : ( + + — + + ); + }, + }, + { + id: 'type', + header: 'Type', + cell: ({ row }) => , + }, + { + 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 }) => , + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + e.stopPropagation()}> + onView(row.original)} title="View"> + + + onEdit(row.original)} title="Edit"> + + + + ), + }, + ]; return ( - - - - - Code - Name - Facility - Type - Location - Weight (cur / cap) - Containers (cur / cap) - Status - Actions - - - - {warehouses.map((warehouse) => ( - - - onView(warehouse)}> - {warehouse.code} - - - {warehouse.name} - - {warehouse.stationId && stationNameById.get(warehouse.stationId) ? ( - - {stationNameById.get(warehouse.stationId)} - - ) : ( - - — - - )} - - - - - {warehouse.locationName ?? '—'} - {formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)} - {formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)} - - - - - - onView(warehouse)} title="View"> - - - onEdit(warehouse)} title="Edit"> - - - - - - ))} - -
-
+ onView(warehouse)} + emptyMessage="No warehouses found." + containerClassName="border-0 shadow-none" + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index f3b8a55d5..1808a78bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -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 ( {status} @@ -40,9 +40,8 @@ const inventoryStatusColor: Record = { 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 }) { 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/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index e1dedc304..2e5b9ee0f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -12,6 +12,7 @@ import { Box, } from "@mantine/core"; +import { PageContainer } from "@/components/page"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; @@ -61,7 +62,7 @@ export default function BookingRequestDetailPage() { if (isLoading) { return ( - +
@@ -70,13 +71,13 @@ export default function BookingRequestDetailPage() {
-
+ ); } if (isError || !booking) { return ( - +
@@ -111,7 +112,7 @@ export default function BookingRequestDetailPage() { - + ); } @@ -127,16 +128,15 @@ export default function BookingRequestDetailPage() { booking.status === "APPROVED_PENDING_SIGNATURE"; return ( - - - + + - + } onClick={() => navigate(`/dashboard/booking-requests/${booking.id}/contract`) @@ -206,8 +206,7 @@ export default function BookingRequestDetailPage() { - - - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 38d1acec6..ecf08bdfc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -1,32 +1,45 @@ +import { + ActionIcon, + Box, + Button, + Card, + Group, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertTriangle, + ArrowRight, + Calendar, + CheckCircle2, + Clock, + LayoutList, + Package, + Plus, + RefreshCw, + Search, + User, + X, +} from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react"; -import { - Container, - Stack, - Group, - Text, - Card, - TextInput, - ActionIcon, - Tabs, -} from "@mantine/core"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusTabs, type BookingStatusTabKey, } from "@/components/bookings/BookingStatusTabs"; -import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader"; -import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; -import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; -import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue"; import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings"; -import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { @@ -36,13 +49,12 @@ import { } from "@/hooks/bookings/useBookings"; import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListRow } from "@/types/booking"; -import { cn } from "@/lib/utils"; import { + Badge, DataTable, DataTableFooter, - type ColumnDef, usePagination, - Badge, + type ColumnDef, } from "@edr/ui-common"; function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { @@ -51,13 +63,25 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined { return match.statuses.join(","); } +function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + type OperationsSubTab = "ready" | "scheduled"; export default function BookingRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); - const [activeTab, setActiveTab] = useState("in_approval"); + const [activeTab, setActiveTab] = useState("all"); const [operationsSubTab, setOperationsSubTab] = useState("ready"); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); @@ -252,7 +276,7 @@ export default function BookingRequestsPage() { cell: ({ row }) => ( - {row.original.scheduledDate} + {formatDate(row.original.scheduledDate)} ), }, @@ -263,29 +287,9 @@ export default function BookingRequestsPage() { ), }, - { - id: "amount", - header: () => ( - Amount - ), - cell: ({ row }) => { - const b = row.original; - return ( - - {b.paymentCurrency}{" "} - {b.totalAmount.toLocaleString(undefined, { - minimumFractionDigits: 2, - })} - - ); - }, - }, { id: "actions", size: 140, - header: () => ( - Actions - ), cell: ({ row }) => ( - - + + + + + + + } + /> - - navigate("/dashboard/booking-requests/new")} - onRefresh={handleRefresh} + items={[ + { + label: "In queue", + value: metrics?.inQueue ?? 0, + icon: LayoutList, + color: "edr-green", + }, + { + label: "Needs action", + value: metrics?.needsAction ?? 0, + icon: Clock, + color: "yellow", + }, + { + label: "Urgent", + value: metrics?.urgent ?? 0, + icon: AlertTriangle, + color: "red", + }, + { + label: "Completed", + value: tabCounts?.completed ?? 0, + icon: CheckCircle2, + color: "edr-green", + }, + ]} /> - - - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query && ( - setQuery("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - - {total} record{total !== 1 ? "s" : ""} - - + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + {isOperationsTab ? ( - - - setOperationsSubTab((value as OperationsSubTab) ?? "ready") - } - > - - Ready to allocate - On train / scheduled - - - {isError ? ( - - ) : operationsSubTab === "ready" ? ( - - ) : ( - - )} - + + + + setOperationsSubTab((value as OperationsSubTab) ?? "ready") + } + > + + Ready to allocate + On train / scheduled + + + {isError ? ( + + ) : operationsSubTab === "ready" ? ( + + ) : ( + + )} + + ) : showEmpty ? ( - + + + ) : ( -
+ -
+ )}
@@ -441,7 +479,6 @@ export default function BookingRequestsPage() { initialBookingIds={allocateIds} /> ) : null} -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 042067edb..3f5323c7e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -147,7 +147,7 @@ function FormSection({ icon: Icon, title, subtitle, - accent = "green", + accent = "edr-green", right, children, }: { @@ -396,7 +396,7 @@ export default function NewBookingPage() { {/* LEFT — form */} - + - + Summary diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index b3d4cc63e..8bcec226c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -132,7 +132,7 @@ const OverviewPage = () => { value={activeTab} onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")} variant="pills" - color="green" + color="edr-green" keepMounted={false} classNames={{ list: "ov-tablist", tab: "ov-tab" }} > @@ -151,12 +151,7 @@ const OverviewPage = () => { size="sm" radius="sm" variant={isActive ? "white" : "light"} - color={isActive ? "green" : "gray"} - styles={ - isActive - ? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } } - : undefined - } + color={isActive ? "edr-green" : "gray"} > {getTabBadge(tab)} diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx index 9cb0e94e3..acc9b91c0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx @@ -1,33 +1,47 @@ import { useMemo, useState } from "react"; import { - AlertCircle, - Filter, + ActionIcon, + Badge, + Box, + Button, + Card, + Code, + Group, + Stack, + Table, + Text, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { FileUp, HardDrive, Layers, - Loader2, Paperclip, Pencil, Plus, Search, Settings, Trash2, + X, } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { api } from "@/services/api"; +import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings"; +import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; -// import Breadcrumbs from "@/components/Breadcrumbs"; import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog"; import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog"; import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; -import { getMinFiles } from "@/types/fileUploadSettings"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "@/services/api"; -import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings"; export default function FileUploadSettingsPage() { const [query, setQuery] = useState(""); - const { data, isLoading, isError, error } = useQuery( + const { data, isLoading, isError, error, refetch } = useQuery( api.fileUploadSettings.list.queryOptions(), ); const deleteMutation = useDeleteFileUploadSetting(); @@ -66,397 +80,298 @@ export default function FileUploadSettingsPage() { 0, ); - return ( -
-
- + const columns = useMemo[]>(() => { + const headerClassName = ruleEngineTable.headerCell; + const cellClassName = ruleEngineTable.bodyCell; + return [ + { + id: "setting", + header: "Setting", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const s = row.original; + return ( + + + + + + + {s.label} + + + {s.description ?? "No description"} + + + + ); + }, + }, + { + id: "code", + header: "Code", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => {row.original.code}, + }, + { + id: "entity", + header: "Entity", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + {row.original.entity ?? "—"} + + ), + }, + { + id: "fields", + header: "Fields", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + + + {row.original.fields.length} + + + ), + }, + { + id: "requiredMulti", + header: "Required / Multi", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const required = row.original.fields.filter((f) => f.isRequired).length; + const multi = row.original.fields.filter((f) => f.isMultiple).length; + return ( + + + {required} required + + + {multi} multi + + + ); + }, + }, + { + id: "maxSize", + header: "Max size", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const maxSize = Math.max( + 0, + ...row.original.fields.map((f) => f.maxSizeMb), + ); + return ( + + + {maxSize ? `${maxSize} MB` : "—"} + + ); + }, + }, + { + id: "actions", + header: "Actions", + meta: { + headerClassName, + cellClassName: `${cellClassName} whitespace-nowrap`, + }, + cell: ({ row }) => { + const setting = row.original; + return ( + + + + - {/* Header */} -
-
-

- File Upload Settings -

-

- Define the file inputs every form in the platform should render — - required/optional, single/multiple, allowed types and size. -

-
+ + + + + -
-
- - 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/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/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index 48ab5e429..9206372e4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -751,7 +751,7 @@ export function WagonTypesCrudPage() { {type.lengthMeters} {type.supportedLoadTypes?.join(', ') || '-'} - + {type.isActive === false ? 'Inactive' : 'Active'} 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 f246ad194..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,7 +1,18 @@ -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 FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; @@ -9,16 +20,16 @@ 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, @@ -26,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; @@ -249,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 ; } @@ -291,8 +351,26 @@ const FleetResourcePage = () => { const itemLabel = config.label.toLowerCase(); return ( - - + + } + onClick={() => { + setEditing(null); + setFormOpen(true); + }} + > + {config.addLabel} + + } + /> + + + + { onSearchChange={setSearch} searchPlaceholder={config.searchPlaceholder} showSearch={config.supportsSearch} - addLabel={config.addLabel} - onAdd={() => { - setEditing(null); - setFormOpen(true); - }} viewMode={viewMode} onViewModeChange={setViewMode} filters={ @@ -315,7 +388,6 @@ const FleetResourcePage = () => { key={filter.key} size="sm" radius="lg" - label={filter.label} value={filter.value} onChange={(v) => { if (!v) return; @@ -435,7 +507,7 @@ const FleetResourcePage = () => { - + ); }; 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 bd4a3364c..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,6 +17,7 @@ import { Tooltip, } from "@mantine/core"; +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"; @@ -101,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) => ({ @@ -241,7 +245,7 @@ export default function RoutesPage() { header: "Status", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - + {row.original.isActive ? "Active" : "Inactive"} ), @@ -279,16 +283,38 @@ export default function RoutesPage() { }, [deactivateMutation.isPending]); return ( - - + + } onClick={openCreate}> + Add route + + } + /> + + + + @@ -338,7 +364,7 @@ export default function RoutesPage() { {route.name} - + {route.isActive ? "Active" : "Inactive"} @@ -431,7 +457,7 @@ export default function RoutesPage() { - @@ -484,6 +510,6 @@ export default function RoutesPage() { ) : null} - + ); } 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..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,18 +1,15 @@ -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 +19,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 = [ @@ -67,7 +59,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [ ]; const STATUS_COLORS: Record = { - success: "green", + success: "edr-green", processing: "yellow", "action-required": "yellow", failed: "red", @@ -75,57 +67,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 +113,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 +187,155 @@ export default function PaymentsPage() { ]; return ( -
- - + + - - - - - - - - + - { - 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} - - ); - })} - - + { + 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("")} - > - - - ) - } - style={{ flex: 1, minWidth: "200px" }} - radius="lg" - /> - { + 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/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index 4ee2a7d96..bc6e5bb15 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -1,12 +1,13 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { + ActionIcon, Alert, Box, Button, Card, - Container, Group, + Menu, Paper, RingProgress, Select, @@ -21,7 +22,9 @@ import { ArrowRight, CalendarClock, CalendarDays, + Eye, Inbox, + MoreHorizontal, Package, Ruler, Train, @@ -31,7 +34,7 @@ import { import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; -import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { @@ -40,7 +43,7 @@ import { totalBookingCount, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; -import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; +import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; @@ -365,7 +368,7 @@ function CardSkeleton() { export default function BatchBoardPage() { const navigate = useNavigate(); - const { data, isLoading, isFetching, refetch } = useBatchBoard(); + const { data, isLoading, isError, isFetching, refetch } = useBatchBoard(); const { viewMode, setViewMode } = useFleetViewMode("batch-board"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); @@ -513,12 +516,12 @@ export default function BatchBoardPage() { style={{ padding: "2px 8px", 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)", }} > - - + + {row.original.capacity.allocatedWagons} @@ -549,72 +552,69 @@ export default function BatchBoardPage() { header: "", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => ( - - + e.stopPropagation()}> + + + + + + + + } + onClick={() => + navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`) + } + > + View windows + + + ), }, ]; }, [navigate]); - const tableStatus = isLoading ? "loading" : "success"; + const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; return ( - - + + void refetch()}> + Refresh + + } + /> - - - - - + - + + navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`) + } + error={ + isError + ? { + message: "Failed to load the batch board.", + onRetry: () => void refetch(), + } + : undefined + } emptyMessage="No active schedules" pagination={{ pageIndex: pagination.pageIndex, @@ -688,7 +699,6 @@ export default function BatchBoardPage() { borderRadius: 20, background: "white", border: "1px solid var(--mantine-color-gray-2)", - boxShadow: "0 4px 12px rgba(15,23,42,0.06)", }} > @@ -711,18 +721,6 @@ export default function BatchBoardPage() { )} - - - - - + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 311f76124..f78639359 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Accordion, @@ -8,13 +7,10 @@ import { Badge, Box, Button, - Container, Group, Loader, Paper, - SimpleGrid, Stack, - Table, Tabs, Text, ThemeIcon, @@ -41,8 +37,11 @@ import { Weight, XCircle, } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +import { KpiStrip, PageContainer } from "@/components/page"; +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor"; @@ -52,8 +51,6 @@ import { totalBookingCount, WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; -import { MiniRing, MiniSparkline } from "@/components/common/MiniGraph"; -import type { OverviewAccent } from "@/components/overview/overview.styles"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; import { useBatchBoardDetail, @@ -72,7 +69,7 @@ const STATE_META: Record< BatchBoardBookingState, { label: string; color: string; icon: typeof CheckCircle2 } > = { - ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 }, + ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 }, SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock }, READY: { label: "Ready for batch", color: "teal", icon: Hourglass }, WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass }, @@ -84,7 +81,7 @@ const ALLOC_META: Record< BookingAllocationStatus, { label: string; color: string } > = { - ASSIGNED: { label: "Wagons assigned", color: "green" }, + ASSIGNED: { label: "Wagons assigned", color: "edr-green" }, NOT_ATTEMPTED: { label: "Not allocated", color: "gray" }, DEFERRED: { label: "Deferred", color: "orange" }, FAILED: { label: "Allocation failed", color: "red" }, @@ -151,140 +148,153 @@ function AllocationBadge({ ); } -function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { - if (!bookings.length) { - return ( - - No bookings in this batch window. +const bookingCellMeta = { + headerClassName: ruleEngineTable.headerCell, + cellClassName: ruleEngineTable.bodyCell, +}; + +const BOOKING_COLUMNS: ColumnDef[] = [ + { + id: "reference", + header: "Reference", + meta: bookingCellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.reference} + + {b.isGovernment ? ( + + Gov + + ) : null} + + ); + }, + }, + { + id: "customer", + header: "Customer", + meta: bookingCellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + + {initials(b.company)} + + + + {b.company} + + + ); + }, + }, + { + id: "contractSigned", + header: "Contract signed", + meta: bookingCellMeta, + cell: ({ row }) => ( + + {fmtDateTime(row.original.fullyExecutedAt)} EAT - ); - } - - const th = (label: string) => ( - - {label} - - ); + ), + }, + { + id: "selectedForBatch", + header: "Selected for batch", + meta: bookingCellMeta, + cell: ({ row }) => { + const b = row.original; + if (!b.selectedForBatchAt) { + return ( + + — + + ); + } + return ( + <> + + {fmtDateTime(b.selectedForBatchAt)} EAT + + {b.paymentDeadline ? ( + + Pay by {fmtDateTime(b.paymentDeadline)} EAT + + ) : null} + + ); + }, + }, + { + id: "capacity", + header: "Capacity", + meta: bookingCellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.wagons}w + + + {fmtTons(b.weightTons)} + + + ); + }, + }, + { + id: "state", + header: "Batch state", + meta: bookingCellMeta, + cell: ({ row }) => , + }, + { + id: "allocation", + header: "Wagon allocation", + meta: bookingCellMeta, + cell: ({ row }) => ( + + ), + }, +]; +function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) { return ( - - - - - {th("Reference")} - {th("Customer")} - {th("Contract signed")} - {th("Selected for batch")} - {th("Capacity")} - {th("Batch state")} - {th("Wagon allocation")} - - - - {bookings.map((b) => ( - - - - - {b.reference} - - {b.isGovernment ? ( - - Gov - - ) : null} - - - - - - - {initials(b.company)} - - - - {b.company} - - - - - - {fmtDateTime(b.fullyExecutedAt)} EAT - - - - {b.selectedForBatchAt ? ( - <> - - {fmtDateTime(b.selectedForBatchAt)} EAT - - {b.paymentDeadline ? ( - - Pay by {fmtDateTime(b.paymentDeadline)} EAT - - ) : null} - - ) : ( - - — - - )} - - - - - {b.wagons}w - - - {fmtTons(b.weightTons)} - - - - - - - - - - - ))} - -
-
+ ); } function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) { const chips: Array<{ value: number; color: string; label: string }> = [ - { value: counts.allocated, color: "green", label: "allocated" }, + { value: counts.allocated, color: "edr-green", label: "allocated" }, { value: counts.selectedForBatch, color: "orange", label: "selected" }, { value: counts.ready, color: "teal", label: "ready" }, { value: counts.waiting, color: "blue", label: "waiting" }, @@ -415,66 +425,6 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) { ); } -/** - * Light stat card matching the overview look — keeps the explicit numbers. - * Capacity cards (pct set) show a ring; count cards show an area/line trend. - */ -function StatCard({ - icon: Icon, - label, - value, - sub, - pct, - variant = "area", -}: { - icon: LucideIcon; - label: string; - value: ReactNode; - sub?: string; - pct?: number | null; - variant?: "area" | "line"; -}) { - const ringAccent: OverviewAccent = - pct == null ? "gold" : pct >= 100 ? "rose" : pct >= 85 ? "orange" : "gold"; - - return ( - - - - - - - - - {value} - - - {label} - {sub ? ` · ${sub}` : ""} - - - {pct != null ? ( - - - {Math.round(pct)}% - - - ) : null} - - - {pct == null ? ( - - ) : null} - - - ); -} - export default function BatchScheduleDetailPage() { const { scheduleId } = useParams<{ scheduleId: string }>(); const navigate = useNavigate(); @@ -621,11 +571,11 @@ export default function BatchScheduleDetailPage() { if (isLoading || !data) { return ( - + - + - + ); } @@ -641,7 +591,7 @@ export default function BatchScheduleDetailPage() { const totalBookings = totalBookingCount(data.counts); return ( - + - + Overview @@ -673,7 +623,7 @@ export default function BatchScheduleDetailPage() { > Back - + <Title order={2} fw={800}> {data.trainNumber ?? data.routeName ?? "Schedule"} @@ -715,7 +665,7 @@ export default function BatchScheduleDetailPage() { Refresh - {["DISPATCHED", "ARRIVED"].includes(row.original.status) ? ( - - ) : null} - {["DRAFT", "SCHEDULED"].includes(row.original.status) ? ( - - ) : null} -
- ), + cell: ({ row }) => { + const schedule = row.original; + return ( + e.stopPropagation()}> + + + + + + + + } + onClick={() => + navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`) + } + > + View detail + + {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? ( + } + onClick={() => + navigate( + `/dashboard/operations/train-scheduling-v2/${schedule.id}/track`, + ) + } + > + Track + + ) : null} + {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + } + onClick={async () => { + try { + await cancel.mutateAsync({ + id: schedule.id, + freightType: schedule.freightType ?? "CONTAINER", + }); + toast({ title: "Schedule cancelled" }); + } catch (err) { + toast({ + title: "Cancel failed", + description: parseError(err, "Could not cancel"), + variant: "destructive", + }); + } + }} + > + Cancel schedule + + ) : null} + + + + ); + }, }, ]; }, [navigate, cancel.isPending, cancel, toast]); @@ -340,65 +355,33 @@ export default function TrainScheduleV2ListPage() { : "success"; return ( - - - - + + } onClick={() => setCreateOpen(true)}> + New schedule + + } + /> - - - - - - + - + setCreateOpen(true)} viewMode={viewMode} onViewModeChange={setViewMode} filters={ @@ -442,6 +425,17 @@ export default function TrainScheduleV2ListPage() { columns={columns} data={paged} status={tableStatus} + onRowClick={(schedule) => + navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`) + } + error={ + schedulesQuery.isError + ? { + message: "Failed to load train schedules.", + onRetry: () => void schedulesQuery.refetch(), + } + : undefined + } emptyMessage="No train schedules found" pagination={{ pageIndex: pagination.pageIndex, @@ -554,13 +548,13 @@ export default function TrainScheduleV2ListPage() { - - + ); } @@ -582,13 +576,13 @@ function MetricChip({ borderRadius: 8, background: subtle ? "var(--mantine-color-gray-1)" - : "var(--mantine-color-green-0)", + : "var(--mantine-color-edr-green-0)", border: `1px solid ${ - subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)" + subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)" }`, }} > - + {value} {label ? ( @@ -613,31 +607,15 @@ function ScheduleCard({ const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status); return ( { - e.currentTarget.style.boxShadow = scheduleBrand.shadowSm; - e.currentTarget.style.transform = "translateY(-2px)"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = ""; - e.currentTarget.style.transform = ""; - }} + className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" > - {/* accent strip */} - - + @@ -675,7 +653,7 @@ function ScheduleCard({ -
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index c05f2c626..0b52d1388 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -4,20 +4,16 @@ import { Badge, Button, Card, - Container, Group, - Loader, - Stack, - Table, Text, } from '@mantine/core'; import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { PageContainer, PageHeader } from '@/components/page'; import { InspectionReportModal, VisualEmptyState, - WarehouseHero, formatDate, } from '@/components/warehouses'; import { useArrivalQueue, useAutoUnloadArrived, useUnloadBooking } from '@/hooks/useWarehouses'; @@ -26,7 +22,7 @@ import type { ArrivalQueueItem } from '@/types/warehouse'; function inspectionBadge(status: string | null) { if (!status) return Not inspected; - const color = status === 'PASSED' ? 'green' : status === 'FAILED' ? 'red' : 'orange'; + const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange'; return {status.replace(/_/g, ' ')}; } @@ -63,138 +59,138 @@ export default function ArrivalQueuePage() { } }; - return ( - - - - - - - - - {items.length} arrived booking(s) - + const columns: ColumnDef[] = [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => ( + + {row.original.bookingReference} + + ), + }, + { id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' }, + { + id: 'cargo', + header: 'Cargo / Container', + cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—', + }, + { + id: 'arrival', + header: 'Arrival', + cell: ({ row }) => {formatDate(row.original.arrivalDate)}, + }, + { id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' }, + { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' }, + { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' }, + { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => + row.original.unloaded ? ( + + {row.original.currentStatus ?? 'RECEIVED'} + + ) : ( + + Not unloaded + + ), + }, + { + id: 'inspection', + header: 'Inspection', + cell: ({ row }) => inspectionBadge(row.original.inspectionStatus), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => { + const item = row.original; + return ( + e.stopPropagation()}> + {!item.unloaded && ( + + )} + {item.inventoryId && ( + + )} + {item.inventoryId && ( + + )} + ); + }, + }, + ]; - {isLoading ? ( - - - - ) : items.length === 0 ? ( - - ) : ( - - - - - Booking - Customer - Cargo / Container - Arrival - Facility - Warehouse - Yard - Zone - Status - Inspection - Actions - - - - {items.map((item) => ( - - - {item.bookingReference} - - {item.customer ?? '—'} - {item.container ?? item.cargo ?? '—'} - - {formatDate(item.arrivalDate)} - - {item.facility ?? '—'} - {item.warehouse ?? '—'} - {item.yard ?? '—'} - {item.zone ?? '—'} - - {item.unloaded ? ( - - {item.currentStatus ?? 'RECEIVED'} - - ) : ( - - Not unloaded - - )} - - {inspectionBadge(item.inspectionStatus)} - - - {!item.unloaded && ( - - )} - {item.inventoryId && ( - - )} - {item.inventoryId && ( - - )} - - - - ))} - -
-
- )} -
-
+ return ( + + } + loading={autoUnload.isPending} + onClick={handleAutoUnload} + > + Auto Unload Arrived Bookings + + } + /> + + + + {items.length} arrived booking(s) + + + {!isLoading && items.length === 0 ? ( + + ) : ( + + )} + setInspectInventoryId(null)} inventoryId={inspectInventoryId} /> -
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx index 58e630fbf..1776ac8e0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/DispatchQueuePage.tsx @@ -1,7 +1,7 @@ -import { Card, Container, Stack } from '@mantine/core'; +import { Card } from '@mantine/core'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { InventoryWorkbench, VisualEmptyState, WarehouseHero } from '@/components/warehouses'; +import { PageContainer, PageHeader } from '@/components/page'; +import { InventoryWorkbench, VisualEmptyState } from '@/components/warehouses'; import { useWarehouseInventory } from '@/hooks/useWarehouses'; /** Items that are LOADED and awaiting dispatch (train departure). */ @@ -10,29 +10,23 @@ export default function DispatchQueuePage() { const items = data ?? []; return ( - - + + - - - - - {!isLoading && items.length === 0 ? ( - - ) : ( - - )} - - - + + {!isLoading && items.length === 0 ? ( + + ) : ( + + )} + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx index 0995796da..eeaaa8848 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from 'react'; -import { Button, Card, Center, Container, Group, Loader, Select, Stack, Text, TextInput, Title } from '@mantine/core'; +import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@mantine/core'; import { Search } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { PageContainer, PageHeader } from '@/components/page'; import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses'; import { useInventoryInquiry, @@ -43,113 +43,107 @@ export default function InventoryInquiryPage() { }; return ( - - + + - -
- Inventory Inquiry - - Locate any cargo, container or goods inside the warehouse network. - -
- - - - - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} - w={200} - /> - { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} - w={180} - /> - setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} - w={180} - /> - setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} - w={180} - /> - - - - - - - - - - - {isFetching ? ( -
- -
- ) : results.length === 0 ? ( - + + + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }} + w={200} /> - ) : ( - - )} -
-
-
+ { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }} + w={200} + /> + { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }} + w={180} + /> + setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + w={180} + /> + setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} + w={180} + /> + + + + + + +
+ + + + {isFetching ? ( +
+ +
+ ) : results.length === 0 ? ( + + ) : ( + + )} +
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx index 5cea3de20..d5dbd0b10 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx @@ -1,86 +1,91 @@ -import { Badge, Card, Container, Group, Loader, Stack, Table, Text } from '@mantine/core'; +import { Badge, Card, Group, Text } from '@mantine/core'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { FreightVisual, VisualEmptyState, WarehouseHero, formatDate, formatNumber } from '@/components/warehouses'; +import { PageContainer, PageHeader } from '@/components/page'; +import { FreightVisual, VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; import { useWarehouseLoadings } from '@/hooks/useWarehouses'; +type Loading = NonNullable['data']>[number]; + +const columns: ColumnDef[] = [ + { + id: 'wagon', + header: 'Wagon', + cell: ({ row }) => ( + + + + {row.original.wagonNumber ?? row.original.wagonId.slice(0, 8)} + + + ), + }, + { + id: 'warehouse', + header: 'Warehouse', + cell: ({ row }) => + row.original.inventory?.warehouse + ? `${row.original.inventory.warehouse.name} (${row.original.inventory.warehouse.code})` + : '—', + }, + { + id: 'zone', + header: 'Zone', + cell: ({ row }) => (row.original.inventory?.zone ? row.original.inventory.zone.name : '—'), + }, + { + id: 'weight', + header: 'Loaded Weight (kg)', + cell: ({ row }) => formatNumber(row.original.loadedWeight), + }, + { + id: 'loadedAt', + header: 'Loaded At', + cell: ({ row }) => formatDate(row.original.loadedAt), + }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => ( + + {row.original.inventory?.status ?? 'LOADED'} + + ), + }, +]; + /** Record of every inventory item loaded onto a wagon. */ export default function LoadedInventoryPage() { const { data, isLoading } = useWarehouseLoadings(); const loadings = data ?? []; return ( - - + + - - - - - {isLoading ? ( - - - - ) : loadings.length === 0 ? ( - - ) : ( - - - - - Wagon - Warehouse - Zone - Loaded Weight (kg) - Loaded At - Status - - - - {loadings.map((l) => ( - - - - - - {l.wagonNumber ?? l.wagonId.slice(0, 8)} - - - - - {l.inventory?.warehouse - ? `${l.inventory.warehouse.name} (${l.inventory.warehouse.code})` - : '—'} - - {l.inventory?.zone ? l.inventory.zone.name : '—'} - {formatNumber(l.loadedWeight)} - {formatDate(l.loadedAt)} - - - {l.inventory?.status ?? 'LOADED'} - - - - ))} - -
-
- )} -
-
-
+ + {!isLoading && loadings.length === 0 ? ( + + ) : ( + + )} + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index 7bc363c09..bb65d8fe6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -1,13 +1,13 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Badge, Button, Card, Container, Group, Stack, Table, Tabs, Text } from '@mantine/core'; +import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core'; import { CreditCard, Eye, Truck } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, VisualEmptyState, - WarehouseHero, formatNumber, } from '@/components/warehouses'; import { useAutoLoadReady, useWarehouseInventory } from '@/hooks/useWarehouses'; @@ -53,35 +53,28 @@ export default function LoadingQueuePage() { const unpaidItems = useMemo(() => readyItems.filter((i) => !isPaid(i)), [readyItems]); return ( - - - - - - - + + } loading={autoLoad.isPending} onClick={handleAutoLoad} > Auto Load Ready Items - + } + /> - - + + + {paidItems.length} } @@ -171,10 +164,9 @@ export default function LoadingQueuePage() { )} - - - - + + + ); } @@ -185,63 +177,67 @@ interface PendingPaymentTableProps { /** Read-only view of unpaid ready-for-loading items. No Mark-as-Loaded action. */ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) { + const columns: ColumnDef[] = [ + { + id: 'booking', + header: 'Booking', + cell: ({ row }) => ( + + {row.original.booking?.reference ?? row.original.bookingId?.slice(0, 8) ?? '—'} + + ), + }, + { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' }, + { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' }, + { id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) }, + { + id: 'payment', + header: 'Payment', + cell: ({ row }) => ( + + {row.original.booking?.status ?? row.original.booking?.paymentStatus ?? 'UNPAID'} + + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => { + const item = row.original; + return ( + e.stopPropagation()}> + + + + ); + }, + }, + ]; + return ( - - - - - Booking - Warehouse - Zone - Weight (kg) - Payment - Actions - - - - {items.map((item) => ( - - - - {item.booking?.reference ?? item.bookingId?.slice(0, 8) ?? '—'} - - - {item.warehouse?.code ?? '—'} - {item.zone?.code ?? '—'} - {formatNumber(item.weight)} - - - {item.booking?.status ?? item.booking?.paymentStatus ?? 'UNPAID'} - - - - - - - - - - ))} - -
-
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index b85227152..be8cbf7b4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { Card, Center, Container, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; +import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core'; import { ClipboardCheck, ClipboardList, @@ -15,22 +15,17 @@ import { Layers, } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses'; +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseDashboardCharts } from '@/components/warehouses'; import { useWarehouseDashboard } from '@/hooks/useWarehouses'; import type { WarehouseDashboard } from '@/types/warehouse'; -/** Brand palette: alternating orange + light green. */ -const ORANGE = { solid: '#f08c00', soft: '#fff4e6', border: '#ffd8a8', text: '#e8590c' }; -const GREEN = { solid: '#5bbf4a', soft: '#ebfbee', border: '#b2f2bb', text: '#2f9e44' }; - interface Metric { key: keyof WarehouseDashboard; label: string; icon: React.ReactNode; /** Route to navigate to when the card is clicked. */ to: string; - theme: typeof ORANGE; } const METRICS: Metric[] = [ @@ -53,60 +48,36 @@ export default function WarehouseDashboardPage() { const { data, isLoading } = useWarehouseDashboard(); return ( - - + + - - - - {isLoading ? ( -
- -
- ) : ( - <> + {isLoading ? ( +
+ +
+ ) : ( + <> {METRICS.map((metric) => ( navigate(metric.to)} - style={{ - cursor: 'pointer', - background: `linear-gradient(135deg, ${metric.theme.soft} 0%, #ffffff 75%)`, - border: `1px solid ${metric.theme.border}`, - transition: 'box-shadow 150ms ease, transform 150ms ease', - }} - onMouseEnter={(e) => { - e.currentTarget.style.boxShadow = `0 10px 24px -12px ${metric.theme.solid}`; - e.currentTarget.style.transform = 'translateY(-3px)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = ''; - e.currentTarget.style.transform = ''; - }} + className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" >
- + {metric.label} - + {data ? data[metric.key] : 0}
- + {metric.icon}
@@ -115,9 +86,8 @@ export default function WarehouseDashboardPage() {
- - )} -
-
+ + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx index 0d352d205..145d3579d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDetailPage.tsx @@ -8,17 +8,16 @@ import { Container, Group, Loader, - SimpleGrid, Stack, Select, - Table, Tabs, Text, - Title, } from '@mantine/core'; import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; + +import { KpiStrip, PageContainer, PageHeader } from '@/components/page'; import { CreateYardModal, CreateZoneModal, @@ -36,19 +35,6 @@ import { } from '@/hooks/useWarehouses'; import type { WarehouseYard, WarehouseZone } from '@/types/warehouse'; -function StatCard({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - - {value} - - - ); -} - export default function WarehouseDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -73,6 +59,90 @@ export default function WarehouseDetailPage() { [yards], ); + const yardColumns = useMemo[]>( + () => [ + { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, + { id: 'code', header: 'Code', cell: ({ row }) => row.original.code }, + { id: 'type', header: 'Type', cell: ({ row }) => humanizeEnum(row.original.type) }, + { + 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 }) => , + }, + { + id: 'actions', + header: 'Actions', + meta: { headerClassName: 'text-right', cellClassName: 'text-right' }, + cell: ({ row }) => ( + { + setEditingYard(row.original); + setYardModalOpen(true); + }} + > + + + ), + }, + ], + [], + ); + + const zoneColumns = useMemo[]>( + () => [ + { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, + { id: 'code', header: 'Code', cell: ({ row }) => row.original.code }, + { id: 'type', header: 'Type', cell: ({ row }) => humanizeEnum(row.original.type) }, + { + 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 }) => , + }, + { + id: 'actions', + header: 'Actions', + meta: { headerClassName: 'text-right', cellClassName: 'text-right' }, + cell: ({ row }) => ( + { + setEditingZone(row.original); + setZoneModalOpen(true); + }} + > + + + ), + }, + ], + [], + ); + if (isLoading) { return (
@@ -95,65 +165,61 @@ export default function WarehouseDetailPage() { } return ( - - + + + + + } /> - - - - navigate('/dashboard/warehouses')}> - - -
- - {warehouse.name} - - - - - {warehouse.code} - {warehouse.locationName ? ` · ${warehouse.locationName}` : ''} - -
-
-
+ + + }> + Overview + + }> + Yards + + }> + Zones + + }> + Inventory + + - - - }> - Overview - - }> - Yards - - }> - Zones - - }> - Inventory - - - - {/* OVERVIEW */} - - - - - - - - + {/* OVERVIEW */} + + + {/* YARDS */} @@ -173,51 +239,27 @@ export default function WarehouseDetailPage() { - {yards.length === 0 ? ( - - No yards yet. - - ) : ( - - - - Name - Code - Type - Weight (cur / cap) - Containers (cur / cap) - Status - Actions - - - - {yards.map((yard) => ( - - {yard.name} - {yard.code} - {humanizeEnum(yard.type)} - {formatCapacity(yard.currentWeight, yard.capacityWeight)} - {formatCapacity(yard.currentContainers, yard.capacityContainers)} - - - - - { - setEditingYard(yard); - setYardModalOpen(true); - }} - > - - - - - ))} - -
- )} + void yardsQuery.refetch(), + } + : undefined + } + />
@@ -253,50 +295,28 @@ export default function WarehouseDetailPage() { Select a yard to view its zones. - ) : (zonesQuery.data ?? []).length === 0 ? ( - - No zones in this yard yet. - ) : ( - - - - Name - Code - Type - Weight (cur / cap) - Containers (cur / cap) - Status - Actions - - - - {(zonesQuery.data ?? []).map((zone) => ( - - {zone.name} - {zone.code} - {humanizeEnum(zone.type)} - {formatCapacity(zone.currentWeight, zone.capacityWeight)} - {formatCapacity(zone.currentContainers, zone.capacityContainers)} - - - - - { - setEditingZone(zone); - setZoneModalOpen(true); - }} - > - - - - - ))} - -
+ void zonesQuery.refetch(), + } + : undefined + } + /> )} @@ -308,8 +328,7 @@ export default function WarehouseDetailPage() { - - + {id && ( )} -
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 8831037d1..234c37826 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { Button, Card, Container, Group, Select, Stack, Text, TextInput, Title } from '@mantine/core'; +import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { PackagePlus, Search } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, ReceiveInventoryModal, @@ -52,79 +52,73 @@ export default function WarehouseInventoryPage() { ); return ( - - - - - -
- Warehouse Inventory - - Track received items through the storage, reservation, loading and dispatch lifecycle. - -
+ + } onClick={() => setModalOpen(true)}> Receive Inventory -
+ } + /> - - - - } - value={search} - onChange={(e) => setSearch(e.currentTarget.value)} - w={220} - /> - setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} - w={200} - /> - setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} - w={200} - /> - + + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + w={220} + /> + setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))} + w={200} + /> + setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} + w={200} + /> + - - - - + +
+ setModalOpen(false)} /> -
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 3f680bf0f..7b1ae4d30 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Card, - Container, Divider, Group, Loader, @@ -17,9 +16,9 @@ import { TextInput, } from '@mantine/core'; import { Ban, CreditCard, Eye, Search } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { WarehouseHero } from '@/components/warehouses'; +import { PageContainer, PageHeader } from '@/components/page'; import { useToast } from '@/hooks/use-toast'; import { useCancelInvoice, @@ -37,7 +36,7 @@ const STATUS_COLOR: Record = { DRAFT: 'gray', ISSUED: 'orange', PARTIALLY_PAID: 'yellow', - PAID: 'green', + PAID: 'edr-green', CANCELLED: 'gray', }; @@ -58,19 +57,65 @@ export default function WarehouseInvoicesPage() { return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q)); }, [invoices, search]); - return ( - - - - + const invoiceColumns: ColumnDef[] = [ + { + id: 'invoiceNumber', + header: 'Invoice No', + cell: ({ row }) => ( + + {row.original.invoiceNumber} + + ), + }, + { id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') }, + { id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) }, + { id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) }, + { + id: 'balance', + header: 'Balance', + cell: ({ row }) => fmt(row.original.balanceAmount, row.original.currency), + }, + { + id: 'status', + header: 'Status', + cell: ({ row }) => ( + + {row.original.status.replace(/_/g, ' ')} + + ), + }, + { + id: 'issued', + header: 'Issued', + cell: ({ row }) => {fmtDate(row.original.issuedAt)}, + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + e.stopPropagation()}> + setDetailId(row.original.id)} + title="View" + > + + + + ), + }, + ]; - - + return ( + + + + + } @@ -88,46 +133,17 @@ export default function WarehouseInvoicesPage() { /> - {isLoading ? ( - - ) : filtered.length === 0 ? ( - No invoices found. - ) : ( - - - - - Invoice NoTypeTotal - PaidBalanceStatus - IssuedActions - - - - {filtered.map((inv) => ( - - {inv.invoiceNumber} - {inv.invoiceType.replace(/_/g, ' ')} - {fmt(inv.totalAmount, inv.currency)} - {fmt(inv.paidAmount, inv.currency)} - {fmt(inv.balanceAmount, inv.currency)} - {inv.status.replace(/_/g, ' ')} - {fmtDate(inv.issuedAt)} - - setDetailId(inv.id)} title="View"> - - - - - ))} - -
-
- )} -
-
+ + setDetailId(null)} /> -
+ ); } @@ -217,7 +233,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => onChange={(v) => setPayAmount(v === '' ? '' : Number(v))} style={{ flex: 1 }} /> - diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index b0d71834e..3f9df44f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button, Card, Center, Container, Group, Loader, Stack, Text, Title } from '@mantine/core'; +import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { Plus } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { PageContainer, PageHeader } from '@/components/page'; import { CreateWarehouseModal, WarehouseCardView, @@ -42,44 +42,38 @@ export default function WarehouseListPage() { const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`); return ( - - - - - -
- Warehouses - - Manage warehouses, yards and zones. - -
+ + } onClick={openCreate}> Create Warehouse -
+ } + /> - - - + + + - {isLoading ? ( -
- -
- ) : isError ? ( - - Failed to load warehouses. - - ) : view === 'table' ? ( - - ) : ( - - )} -
-
-
+ {isLoading ? ( +
+ +
+ ) : isError ? ( + + Failed to load warehouses. + + ) : view === 'table' ? ( + + ) : ( + + )} +
+ setModalOpen(false)} warehouse={editing} /> -
+ ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 1bfce1de1..af5adb83d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -4,22 +4,19 @@ import { Badge, Button, Card, - Container, Group, - Loader, Modal, NumberInput, Select, Stack, - Table, Tabs, Text, TextInput, } from '@mantine/core'; import { Plus, Trash2 } from 'lucide-react'; +import { DataTable, type ColumnDef } from '@edr/ui-common'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { WarehouseHero } from '@/components/warehouses'; +import { PageContainer, PageHeader } from '@/components/page'; import { useToast } from '@/hooks/use-toast'; import { useAllocationRules, @@ -45,31 +42,26 @@ const clean = (s: string) => s.trim() || undefined; export default function WarehouseRulesPage() { return ( - - - - - - - - Allocation Rules - Storage / Demurrage Fees - - - - - - - - - - - + + + + + + Allocation Rules + Storage / Demurrage Fees + + + + + + + + + + ); } @@ -112,45 +104,52 @@ function AllocationRules() { setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' }); }; + const columns: ColumnDef<(typeof rules)[number]>[] = [ + { id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority }, + { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, + { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' }, + { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' }, + { id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' }, + { + id: 'targetYard', + header: 'Target yard', + cell: ({ row }) => {row.original.targetYardCode}, + }, + { + id: 'active', + header: 'Active', + cell: ({ row }) => ( + + {row.original.isActive ? 'Yes' : 'No'} + + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + e.stopPropagation()}> + remove.mutate(row.original.id)} title="Delete"> + + + + ), + }, + ]; + return ( <> {rules.length} rule(s) — matched by ascending priority - + - {isLoading ? ( - - ) : ( - - - - - PriorityNameFreight - TradeCargo codeTarget yard - ActiveActions - - - - {rules.map((r) => ( - - {r.priority} - {r.name} - {r.freightType ?? '—'} - {r.tradeDirection ?? '—'} - {r.cargoTypeCode ?? '—'} - {r.targetYardCode} - {r.isActive ? 'Yes' : 'No'} - - remove.mutate(r.id)} title="Delete"> - - - - - ))} - -
-
- )} + setOpen(false)} title="New allocation rule" centered size="lg"> @@ -172,7 +171,7 @@ function AllocationRules() { - + @@ -218,45 +217,60 @@ function FeeRules() { setOpen(false); }; + const columns: ColumnDef<(typeof rules)[number]>[] = [ + { + id: 'type', + header: 'Type', + cell: ({ row }) => ( + + {row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + + ), + }, + { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, + { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' }, + { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' }, + { id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays }, + { + id: 'rate', + header: 'Rate / day', + cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`, + }, + { + id: 'active', + header: 'Active', + cell: ({ row }) => ( + + {row.original.isActive ? 'Yes' : 'No'} + + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + e.stopPropagation()}> + remove.mutate(row.original.id)} title="Delete"> + + + + ), + }, + ]; + return ( <> {rules.length} rule(s) — most specific match applies - + - {isLoading ? ( - - ) : ( - - - - - TypeNameFreight - TradeFree daysRate / day - ActiveActions - - - - {rules.map((r) => ( - - {r.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} - {r.name} - {r.freightType ?? '—'} - {r.tradeDirection ?? '—'} - {r.freeDays} - {Number(r.ratePerDay).toLocaleString()} {r.currency} - {r.isActive ? 'Yes' : 'No'} - - remove.mutate(r.id)} title="Delete"> - - - - - ))} - -
-
- )} + setOpen(false)} title="New fee rule" centered size="lg"> @@ -276,7 +290,7 @@ function FeeRules() { - + 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)", }); 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}