From 8389b2735d6f1eaf8ca6687adb8f9f644f53934b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 11:54:54 +0000 Subject: [PATCH 01/37] fix: sidebar in user management --- apps/edr-freight-web/backoffice/src/App.tsx | 16 +- .../src/layout/components/TenantConfig.ts | 8 +- .../components/common/Top.tsx | 94 ++--- .../src/user-management/AppMenuTabs.tsx | 379 +++++++----------- .../src/user-management/Applayout.tsx | 83 +++- 5 files changed, 271 insertions(+), 309 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f8e17fe76..70bcc4f9b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -626,7 +626,9 @@ const filterSidebarByPermission = ( const filterItems = (items: SidebarItem[]): SidebarItem[] => items .map((item) => - item.children ? { ...item, children: filterItems(item.children) } : item, + item.children + ? { ...item, children: filterItems(item.children) } + : item, ) .filter((item) => { if (etGl || djGl) { @@ -642,18 +644,6 @@ const filterSidebarByPermission = ( return true; }); - // Recursive: a group's own permission gates the whole subtree, leaves are - // checked individually, and a group with no surviving children disappears. - const filterItems = (items: SidebarItem[]): SidebarItem[] => - items.flatMap((item) => { - if (item.children?.length) { - if (item.permission && !permissionAllowed(item)) return []; - const children = filterItems(item.children); - return children.length ? [{ ...item, children }] : []; - } - return itemAllowed(item) ? [item] : []; - }); - return sections .map((section) => ({ ...section, diff --git a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts index 758e2a75a..f8fd1f47d 100644 --- a/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts +++ b/apps/edr-freight-web/backoffice/src/layout/components/TenantConfig.ts @@ -88,8 +88,8 @@ export const resolveModuleConfig = (config: TenantConfig): ModuleConfig => ({ }); const defaultConfig: TenantConfig = { - appName: "Smart Office", - organizationName: "Smart Office", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", canUseAttachmentFromDMS: false, logo: "/assets/TriaTradinglogo.png", primaryColor: "#1b354d", @@ -116,8 +116,8 @@ const defaultConfig: TenantConfig = { const tenantConfigs: Record = { localhost: { - appName: "Smart Office", - organizationName: "Addis Ababa City Administration", + appName: "EDR Freight", + organizationName: "Ethio-Djibouti Railways", logo: "", primaryColor: "#0EA371", moduleConfig: { diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index 507e7da74..71afbee2d 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -104,9 +104,9 @@ const Top: React.FC = ({ const activePositionName = currentLanguage === "am" ? userDetails?.employee?.[0]?.positions?.[0]?.name?.am || - userDetails?.employee?.[0]?.positions?.[0]?.name?.en + userDetails?.employee?.[0]?.positions?.[0]?.name?.en : userDetails?.employee?.[0]?.positions?.[0]?.name?.en || - userDetails?.employee?.[0]?.positions?.[0]?.name?.am; + userDetails?.employee?.[0]?.positions?.[0]?.name?.am; const normalizedUserType = userDetails?.userType?.trim().toLowerCase() || ""; const userTypeLabel = @@ -114,8 +114,8 @@ const Top: React.FC = ({ ? t("header.user") : normalizedUserType ? normalizedUserType - .replace(/[_-]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""; const roleLabel = @@ -123,8 +123,8 @@ const Top: React.FC = ({ userTypeLabel || (normalizedUserType ? userDetails?.roles?.[0]?.key - ?.replace(/[:_]/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + ?.replace(/[:_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) : ""); const { permissionKeys } = useUserDetail(userDetails as MeDto); @@ -159,57 +159,57 @@ const Top: React.FC = ({ }, ...(moduleConfig.recordManagement ? [ - { - id: "recordManagement", - label: t("nav.Record Management", "Record Management"), - path: "/record-management/dashboard", - }, - ] + { + id: "recordManagement", + label: t("nav.Record Management", "Record Management"), + path: "/record-management/dashboard", + }, + ] : []), ...(moduleConfig.performance ? [ - { - id: "performanceManagement", - label: t("nav.PerformanceManagement", "Performance Management"), - path: "/performance-management/plan-years", - }, - ] + { + id: "performanceManagement", + label: t("nav.PerformanceManagement", "Performance Management"), + path: "/performance-management/plan-years", + }, + ] : []), ...(moduleConfig.objective ? [ - { - id: "objectiveManagement", - label: t("nav.objectiveManagement", "Objective Management"), - path: "/objective-management/plan-years", - }, - ] + { + id: "objectiveManagement", + label: t("nav.objectiveManagement", "Objective Management"), + path: "/objective-management/plan-years", + }, + ] : []), ...(moduleConfig.dms ? [ - { - id: "documentManagement", - label: t("nav.DocumentManagement", "Document Management"), - path: "/dms/dashboard", - }, - ] + { + id: "documentManagement", + label: t("nav.DocumentManagement", "Document Management"), + path: "/dms/dashboard", + }, + ] : []), ...(isOrgAdmin && moduleConfig.siteManagement ? [ - { - id: "orgAdmin", - label: t("nav.admin", "Admin"), - path: "/user-management/user_management-dashboard", - }, - ] + { + id: "orgAdmin", + label: t("nav.admin", "Admin"), + path: "/user-management/user_management-dashboard", + }, + ] : []), ...(isSuperAdmin ? [ - { - id: "superAdmin", - label: t("OrganizationAdmin", "Super Admin"), - path: "/user-management/dashboard", - }, - ] + { + id: "superAdmin", + label: t("OrganizationAdmin", "Super Admin"), + path: "/user-management/dashboard", + }, + ] : []), ]; @@ -256,8 +256,8 @@ const Top: React.FC = ({ }; return ( -
-
+
+
@@ -394,7 +394,7 @@ const Top: React.FC = ({ className={cn( "relative h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10", openNotifications && - "bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60", + "bg-primary-50 text-primary-700 ring-primary-300 dark:bg-primary-900/30 dark:text-primary-300 dark:ring-primary-700/60", )} aria-label={t("header.notifications")} onClick={() => @@ -409,7 +409,7 @@ const Top: React.FC = ({ className={cn( "h-4 w-4 transition-colors", openNotifications && - "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", + "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", )} /> {unseenCount > 0 && ( @@ -479,7 +479,7 @@ const Top: React.FC = ({ className={cn( "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", currentLanguage === lang.value && - "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", + "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", )} >
diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx index fb523fc06..127806334 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx @@ -1,4 +1,4 @@ -import { NavLink } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { Archive, BarChart, @@ -8,28 +8,48 @@ import { FileText, Globe, Settings, - ShieldAlert, Users2, UsersRound, } from "lucide-react"; -import { useAuth } from "@/shared/context/AuthContext"; -import { usePermissions } from "@/shared/context/PermissionContext"; - import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/shared/common/ui/sidebar"; + export interface MenuItem { label: string; href: string; icon: React.ReactNode; roles?: string[]; - permissions?: string[]; - isPrimary?: boolean; - displayLabel?: string; - children?: MenuItem[]; + /** Sidebar section this item is bucketed under. */ + group: string; } +// Section render order; groups with no role-visible items are skipped. +const GROUP_ORDER = [ + "Overview", + "Organizations", + "Content", + "Records", + "Configuration", + "Archive", + "System", +]; + export const AppMenuTabs = () => { const { user } = useAuth(); + const { pathname } = useLocation(); + const { setOpenMobile } = useSidebar(); + const { t } = useTranslation(); + const userRoles = user?.roles.map((role) => role.key) || []; const menuItems: MenuItem[] = [ @@ -38,282 +58,193 @@ export const AppMenuTabs = () => { href: "/user-management/dashboard", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Overview", }, { label: "organizations", href: "/user-management/organizations", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, - { - label: "organizationAdmins", // Shortened for mobile - displayLabel: "organizationAdmins", // Full label for desktop + label: "organizationAdmins", href: "/user-management/organization_admins", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { - label: "externalUsers", // Shortened for mobile - displayLabel: "externalUsers", // Full label for desktop + label: "externalUsers", href: "/user-management/external_users", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Organizations", }, { label: "dashboard", href: "/user-management/user_management-dashboard", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, { label: "userManagement", href: "/user-management/user_management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Overview", }, - // { - // label: "userPositionApproval", - // href: "/user-management/user-position-approval", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:activateEmployee"], - // isPrimary: true, - // }, - // { - // label: "All Records", - // displayLabel: "All Records", - // href: "/user-management/all-records", - // icon: , - // roles: ["admin", "organization_admin", "unit_admin"], - // permissions: ["can:canViewAllRecords"], - // isPrimary: true, - // }, { - label: "contentManagement", // Shortened for mobile - displayLabel: "contentManagement", // Full label for desktop + label: "contentManagement", href: "/user-management/content-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "webManagement", - displayLabel: "webManagement", href: "/user-management/web-management", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Position", - displayLabel: "positionTypes", - href: "/user-management/position-management", - icon: , - roles: ["admin", "organization_admin", "unit_admin", "super_admin"], - isPrimary: true, - }, - { - label: "migratedRecords", - displayLabel: "migratedRecords", - href: "/user-management/migrated-records-management", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "settings", - displayLabel: "settings", - href: "/user-management/organization-settings", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { label: "Bulk", - displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: , roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, + group: "Content", }, { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archive-users", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archived Organizations", - displayLabel: "Archived Organizations", - href: "/user-management/archived-organizations", - icon: , - roles: ["super_admin"], - isPrimary: true, - }, - { - label: "Archive Users", - displayLabel: "Archive Users", - href: "/user-management/archives", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Archived Units & Positions", - displayLabel: "Archived Units & Positions", - href: "/user-management/archived", - icon: , - roles: ["admin", "organization_admin", "unit_admin"], - isPrimary: true, - }, - { - label: "Sector Reports", - displayLabel: "Sector Reports", - href: "/user-management/sector-reports", - icon: , - roles: ["unit_admin", "admin", "organization_admin"], - isPrimary: true, - }, - { - label: "activityLog", - href: "/user-management/activity_log", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "setting", - href: "/user-management/settings", - icon: , - roles: ["super_admin"], - isPrimary: false, - }, - { - label: "Letter Template", - href: "/user-management/templates", + label: "Position", + href: "/user-management/position-management", icon: , - roles: ["super_admin"], - isPrimary: false, + roles: ["admin", "organization_admin", "unit_admin", "super_admin"], + group: "Configuration", + }, + { + label: "settings", + href: "/user-management/organization-settings", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Configuration", }, { label: "Add Site", href: "/user-management/add-site", icon: , roles: ["super_admin"], - isPrimary: true, + group: "Configuration", + }, + { + label: "migratedRecords", + href: "/user-management/migrated-records-management", + icon: , + roles: ["super_admin"], + group: "Records", + }, + { + label: "Sector Reports", + href: "/user-management/sector-reports", + icon: , + roles: ["unit_admin", "admin", "organization_admin"], + group: "Records", + }, + { + label: "Archive Users", + href: "/user-management/archive-users", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archived Organizations", + href: "/user-management/archived-organizations", + icon: , + roles: ["super_admin"], + group: "Archive", + }, + { + label: "Archive Users", + href: "/user-management/archives", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "Archived Units & Positions", + href: "/user-management/archived", + icon: , + roles: ["admin", "organization_admin", "unit_admin"], + group: "Archive", + }, + { + label: "activityLog", + href: "/user-management/activity_log", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "setting", + href: "/user-management/settings", + icon: , + roles: ["super_admin"], + group: "System", + }, + { + label: "Letter Template", + href: "/user-management/templates", + icon: , + roles: ["super_admin"], + group: "System", }, - - // { - // label: "branding.title", - // href: "/user-management/web-management/Branding/Branding", - // icon: , - // roles: ["super_admin"], - // isPrimary: true, - // }, ]; - const { permissions } = usePermissions(); - const { t } = useTranslation(); const filteredMenu = menuItems.filter((item) => - item?.roles?.some((r) => userRoles.includes(r)), - ); - const primaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary !== false, - ); - const secondaryMenuItems = filteredMenu.filter( - (item) => item.isPrimary === false, + item.roles?.some((r) => userRoles.includes(r)), ); + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + return ( - // Sticky (not fixed) so it stays in flow: content below never needs a - // magic offset matching this bar's responsive height. top-16 keeps it - // pinned just below the fixed 64px header while scrolling. - // -mt-8 cancels the excess of 's in-flow h-24 wrapper over its 64px - // fixed header, so the bar sits flush under the header with no jump. - // shrink-0 is load-bearing: as a flex item with overflow-hidden this bar - // would otherwise be flex-squashed to zero height when the page overflows. -
- {/* Mobile View - Two separate rows */} -
- {/* Primary items row */} -
- {primaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
+ <> + {GROUP_ORDER.map((group) => { + const items = filteredMenu.filter((item) => item.group === group); + if (items.length === 0) return null; - {/* Secondary items row (if any) */} - {secondaryMenuItems.length > 0 && ( -
- {secondaryMenuItems.map((item) => ( - - `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
- )} -
- - {/* Desktop View - Single row with all items */} -
-
- {filteredMenu.map((item) => ( - - `flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap ${ - isActive - ? "text-primary dark:text-primary-400 border-b-2 border-primary dark:border-primary-400" - : "text-slate-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-400" - }` - } - > - {item.icon} - - {t(`organization.${item.label}`, item.label)} - - - ))} -
-
-
+ return ( + + {group} + + + {items.map((item) => { + const label = t(`organization.${item.label}`, item.label); + return ( + + + setOpenMobile(false)} + > + {item.icon} + {label} + + + + ); + })} + + + + ); + })} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index 3b33519b6..25f8700e8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -1,23 +1,26 @@ -import { useLocation } from "react-router-dom"; -import { Outlet } from "react-router-dom"; +import { Link, Outlet, useLocation } from "react-router-dom"; import { AppMenuTabs } from "./AppMenuTabs"; -import { useSidebar } from "@/shared/common/ui/sidebar"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarRail, + SidebarTrigger, +} from "@/shared/common/ui/sidebar"; import Top from "@/record-management/components/common/Top"; import { useAuth } from "@/shared/context/AuthContext"; export const AppLayout = () => { const { pathname } = useLocation(); const isAuthPage = pathname === "/"; - const { toggleSidebar } = useSidebar(); const { user } = useAuth(); - + const userRoles = user?.roles?.map((role) => role.key) || []; const isSuperAdmin = userRoles.includes("super_admin"); - - // html/body/#root are `overflow: hidden` (index.css) — the host chrome - // scrolls inside FreightDashboardLayout. This subtree renders its own - // full-page layout instead, so it must be its own scroll container or - // nothing scrolls. Top/AppMenuTabs are `fixed`, unaffected by the scroller. + + // Standalone full-page scroll container for the auth screen — the host + // chrome is `overflow: hidden`, so this subtree must scroll itself. if (isAuthPage) { return (
@@ -27,17 +30,55 @@ export const AppLayout = () => { } return ( - // renders its own in-flow h-24 wrapper around the fixed 64px header, - // so flow already clears the header — no extra top padding here. - // AppMenuTabs is sticky and in flow, so content starts right below it. -
- - -
-
- + <> + {/* is a shared component rendered as a full-width `fixed` 64px bar, + so the sidebar is offset to start beneath it (top-16) rather than + owning the top-left corner. Collapses to an icon rail on desktop; on + mobile it's a Sheet drawer opened by the below (Top's + own burger is a module-nav dropdown, not the sidebar toggle). */} + + + + EDR +
+ + User Management + + + EDR Freight + +
+ +
+ + + + +
+ + + + {/* Top provides its own in-flow h-24 spacer clearing the fixed header. */} + {/* Mobile-only drawer opener — desktop shows the sidebar/rail directly. */} +
+ + + Menu +
-
-
+
+
+ +
+
+ + ); }; From 138291050888ae3e94bc3ed4e7e2b5fb0ec69ee6 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 22 Jul 2026 15:41:34 +0300 Subject: [PATCH 02/37] fix: ( payment ) stop re-finalizing already-completed payments on intent read --- .../src/modules/payments/payments.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index b3befbaea..e5846b48f 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -598,6 +598,17 @@ export class PaymentsService { where: { bookingId }, }); + + if (local?.status === PaymentIntentStatus.SUCCEEDED) { + const booking = await this.prisma.booking.findUnique({ + where: { id: bookingId }, + select: { status: true }, + }); + if (booking?.status === "CONFIRMED") { + return this.formatIntentStatus(local); + } + } + // WALLET payments never leave this app — no remote intent exists for them. if (local?.method === PaymentMethodType.WALLET) { return this.formatIntentStatus(local); From 9baffb6439d4d03f8de0b172a415154a1ba52386 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 12:57:56 +0000 Subject: [PATCH 03/37] refactor: remove duplicate header components and consolidate layout --- .../components/common/Header.tsx | 413 ------- .../common/NavigationHeader/Profile.tsx | 1049 ----------------- .../components/common/Top.tsx | 494 ++++---- .../src/user-management/Applayout.tsx | 176 +-- .../position-management/PositionLists.tsx | 2 +- .../pages/position-management/index.tsx | 8 +- 6 files changed, 345 insertions(+), 1797 deletions(-) delete mode 100644 apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx deleted file mode 100644 index 901001c93..000000000 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Header.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { NavLink, useLocation } from "react-router-dom"; -import { cn } from "@/shared/lib/utils"; -import { - LayoutGrid, - Upload, - Download, - CheckSquare, - UserCheck, - FileText, - LucideIcon, - Settings, - Handshake, - BellRing, - BarChart3, -} from "lucide-react"; -import { useUserDetail } from "../hooks/useUserDetail"; -import { useAuthUser } from "@/shared/hooks/useAuthUser"; -import Cookies from "js-cookie"; -import { useDelegations } from "../hooks/useDelegations"; -import Top from "./Top"; - -import { useReport } from "../hooks/useReport"; -import { hasApprovalPermission } from "@/record-management/routes/routes"; -import { useReportByHooks } from "../hooks/useReportByHooks"; -import { useMyCollaborations } from "../hooks/useMyCollaborations"; - -interface HeaderProps { - onToggleSidebar?: () => void; -} - -export interface INavTabs { - icon: LucideIcon; - isVissible?: boolean; - label?: string; - title?: string; - href?: string; - url?: string; - isActive?: boolean; - count?: boolean; - isPrimary?: boolean; - countBadge?: number; - isUrgent?: boolean; -} - -const Header: React.FC = ({ onToggleSidebar }) => { - const baseParams = { - skip: 0, - take: 10, - orderBy: "employeePosition.createdAt:DESC", - }; - const { t } = useTranslation(); - const location = useLocation(); - const { hasDelegated } = useDelegations(baseParams); - const { userDetails, selectedPositionPermissionKeys, selectedPosition } = - useAuthUser(); - const { permissionKeys } = useUserDetail(userDetails); - const useParentCounts = - permissionKeys.includes("can:viewParentPositionRecord") && - new URLSearchParams(location.search).get("view") === "parent"; - const currentPosition = Cookies.get("current-position-id"); - const canViewApprovalTab = hasApprovalPermission( - selectedPositionPermissionKeys, - ); - - // Reuse the auth layer's already-normalized active position instead of - // re-deriving it here. useAuthUser resolves it by matching BOTH `id` and - // `employeePositionId` (and honors the delegated-position cookie), so a - // delegate's position is found and `isDelegate` is reliable. The previous - // local lookup matched `pos.id === selectedPositionId` only — but - // `selectedPositionId` is normalized to `employeePositionId`, so the find - // returned undefined and `!undefined` left every tab visible for delegates. - const canViewDelegationTab = !selectedPosition?.isDelegate; - const userRoles = - userDetails?.roles?.map((r: { key: string }) => r.key) || []; - const showUserManagementShortcut = - userRoles.includes("admin") || - userRoles.includes("unit_admin") || - userRoles.includes("super_admin"); - - const { - dashboard, - ROdashboard, - TotalDraftExternal, - TotalDraftInternal, - TotalDraftCC, - ROTotalDraftIncoming, - ROPending, - TotalUrgentDraftInternal, - TotalUrgentDraftExternal, - ROPendingUrgent, - } = useReport(""); - const { breakdownCounts } = useReportByHooks({ - isSecretary: useParentCounts, - }); - const { data: draftCollaborations } = useMyCollaborations({ - skip: 0, - take: 1, - signStatus: "draft", - }); - const collaborationDraftCount = - draftCollaborations?.count ?? dashboard?.collaborationCount?.draft ?? 0; - const incomingHref = useParentCounts - ? "/record-management/userIncoming?view=parent" - : "/record-management/userIncoming"; - - const navigationTabs = useMemo( - (): INavTabs[] => [ - { - icon: LayoutGrid, - label: t("header.navigation.dashboard"), - href: "/record-management/dashboard", - isActive: location.pathname.includes("/record-management/dashboard"), - isPrimary: true, - // Delegates see only Outgoing, Incoming, Approval — hide Dashboard. - isVissible: canViewDelegationTab, - }, - { - icon: BarChart3, - label: "Reports", - href: "/record-management/sector-reports", - isActive: location.pathname.startsWith( - "/record-management/sector-reports", - ), - isPrimary: false, - isVissible: false, - }, - { - icon: Upload, - label: t("header.navigation.outgoing"), - href: "/record-management/userRecords", - isActive: - location.pathname.startsWith("/record-management/userRecords") && - new URLSearchParams(location.search).get("from") !== "collaborations", - isPrimary: true, - isVissible: true, - }, - { - icon: Download, - label: t("header.navigation.incoming"), - href: incomingHref, - isActive: - location.pathname.startsWith("/record-management/userIncoming") || - location.pathname.startsWith( - "/record-management/viewIncoming/incoming/", - ) || - location.pathname.startsWith( - "/record-management/viewIncoming/internal", - ) || - location.pathname.startsWith("/record-management/viewIncoming/cc"), - isPrimary: true, - isVissible: true, - countBadge: - breakdownCounts.external + - breakdownCounts.externalSmart + - breakdownCounts.internal + - breakdownCounts.ccUnseen + - breakdownCounts.forYourReference, - isUrgent: TotalUrgentDraftInternal > 0 || TotalUrgentDraftExternal > 0, - }, - { - icon: CheckSquare, - label: t("header.navigation.approval"), - href: "/record-management/approval", - isActive: - location.pathname.startsWith("/record-management/approval") || - location.pathname.startsWith("/record-management/viewApproval"), - isPrimary: false, - isVissible: canViewApprovalTab, - countBadge: breakdownCounts?.approval || 0, - isUrgent: - (dashboard?.activeApprovalCount?.myUrgentWorkflowCount || 0) > 0, - }, - { - icon: UserCheck, - label: t("header.navigation.delegation"), - href: "/record-management/delegation", - isActive: location.pathname.startsWith("/record-management/delegation"), - isPrimary: false, - isVissible: canViewDelegationTab, - }, - { - icon: Handshake, - label: t("header.navigation.collaborations"), - href: "/record-management/collaborations", - isActive: - location.pathname.startsWith("/record-management/collaborations") || - new URLSearchParams(location.search).get("from") === "collaborations", - isPrimary: false, - isVissible: canViewDelegationTab, - countBadge: collaborationDraftCount, - }, - { - icon: Settings, - label: t("header.navigation.settings"), - href: "/record-management/uploadTeeterandSignature", - isActive: location.pathname.startsWith( - "/record-management/uploadTeeterandSignature", - ), - isPrimary: false, - isVissible: canViewDelegationTab, - }, - ], - [ - t, - location.pathname, - location.search, - TotalDraftCC, - TotalDraftInternal, - TotalDraftExternal, - TotalUrgentDraftInternal, - TotalUrgentDraftExternal, - canViewApprovalTab, - canViewDelegationTab, - useParentCounts, - incomingHref, - breakdownCounts.approval, - breakdownCounts.ccUnseen, - breakdownCounts.external, - breakdownCounts.externalSmart, - breakdownCounts.forYourReference, - breakdownCounts.internal, - dashboard?.activeApprovalCount?.myNotUrgentWorkflowsCount, - dashboard?.activeApprovalCount?.myUrgentWorkflowCount, - collaborationDraftCount, - ], - ); - - const recordOfficerNavs = useMemo( - (): INavTabs[] => [ - { - icon: LayoutGrid, - label: t("header.navigation.dashboard"), - href: "/record-management/dashboard", - isActive: location.pathname.includes("/dashboard"), - isVissible: true, - }, - { - icon: Upload, - label: t("header.navigation.outgoing"), - href: "/record-management/recordOfficer/outgoing", - isActive: - location.pathname.startsWith( - "/record-management/recordOfficer/outgoing", - ) || - location.pathname.includes("/record-management/view/") || - location.pathname.includes("/record-management/outgoingview/"), - isVissible: true, - }, - { - icon: Download, - label: t("header.navigation.incoming"), - href: "/record-management/recordOfficer/incoming", - isActive: - location.pathname.startsWith( - "/record-management/recordOfficer/incoming", - ) || - location.pathname.includes("/record-management/viewIncoming/") || - location.pathname.includes("/record-management/recordViewIncoming/"), - isVissible: true, - countBadge: ROTotalDraftIncoming, - }, - { - icon: FileText, - label: t("header.navigation.pending"), - href: "/record-management/pending", - isActive: - location.pathname.startsWith("/record-management/pending") || - location.pathname.includes("/record-management/viewPending/"), - isVissible: true, - countBadge: ROPending, - isUrgent: ROPendingUrgent > 0, - }, - ], - [t, location.pathname, ROTotalDraftIncoming, ROPending, ROPendingUrgent], - ); - - const activeNavigationTabs = useMemo(() => { - if (permissionKeys.includes("can:dispatchRecords")) { - return recordOfficerNavs; - } - return navigationTabs.filter((tab) => tab.isVissible); - }, [permissionKeys, navigationTabs, recordOfficerNavs]); - - // const { user } = useAuth(); - // console.log("User Info:", user); - // const organizationId = - // user?.employee && user.employee.length > 0 - // ? user.employee[0].unitId - // : undefined; - - // const unitId = organizationId; - return ( -
- - - {/* Navigation Tabs */} - {currentPosition ? ( -
- {/* Mobile layout: Simple horizontal scrollable (up to sm) */} -
-
- {activeNavigationTabs.map((tab, index) => ( - - cn( - "flex items-center gap-1 px-3 py-2 mr-2 text-xs font-medium transition-colors whitespace-nowrap relative", - isActive - ? "text-primary-800 dark:text-white border-b-2 border-primary-600 dark:border-primary-400" - : "text-gray-700 dark:text-gray-300 hover:text-primary-800 dark:hover:text-white", - ) - } - > - - {tab.label} - - {/* Count Badge - positioned on tab */} - {typeof tab?.countBadge === "number" && - tab.countBadge > 0 && ( - - {tab.countBadge > 99 ? "99+" : tab.countBadge} - - )} - - {/* Urgent Bell - positioned on tab */} - {typeof tab?.isUrgent === "boolean" && tab.isUrgent && ( - - - - - )} - - {/* Live indicator for Delegation */} - {hasDelegated && - (tab.label?.toLowerCase() === "delegation" || - tab.label?.toLowerCase() === "ዉክልና") && ( - - - - - )} - - ))} -
-
- - {/* Desktop layout: All tabs in one row (sm and up) */} -
-
- {activeNavigationTabs.map((tab, index) => ( - - cn( - "flex items-center justify-center px-2.5 py-2.5 rounded-sm text-sm whitespace-nowrap flex-1 font-Urbanist relative", - tab.isActive - ? "bg-primary-50 dark:bg-primary-900/40 text-primary-800 dark:text-white font-medium" - : "text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700", - ) - } - > - - - {tab.label} - - {/* Count Badge (inline) */} - {typeof tab?.countBadge === "number" && - tab.countBadge > 0 && ( - - {tab.countBadge > 99 ? "99+" : tab.countBadge} - - )} - - {/* Urgent Bell (pulsing) */} - {typeof tab?.isUrgent === "boolean" && tab.isUrgent && ( - - {/* Pulse effect */} - - {/* Bell icon */} - - - - - )} - - - {/* Live indicator for Delegation */} - {hasDelegated && - (tab.label?.toLowerCase() === "delegation" || - tab.label?.toLowerCase() === "ዉክልና") && ( - - - - - )} - - ))} -
-
-
- ) : null} -
- ); -}; - -export default Header; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx deleted file mode 100644 index 6d128954a..000000000 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/NavigationHeader/Profile.tsx +++ /dev/null @@ -1,1049 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { Badge } from "@/shared/common/ui/badge"; -import { - ChevronLeft, - Mail, - User, - Briefcase, - Key, - CheckCircle2, - Shield, - Lock, - CircleUser, - Star, - ShieldCheck, - Clock, - UserPen, - ShieldOff, - IdCard, - Settings, - Building, - Users, - FileText, - ChevronDown, - LogOut, - Monitor, -} from "lucide-react"; -import { Button } from "@/shared/common/ui/button"; -import { NavLink, useNavigate } from "react-router-dom"; -import { useAuthUser } from "../../../hooks/useAuthUser"; -import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import Top from "../Top"; -import { Label } from "@/shared/common/ui/label"; -import { Switch } from "@/shared/common/ui/switch"; -import { useSessions } from "@/shared/hooks/useSession"; -import { useQueryClient } from "@tanstack/react-query"; -import Loader from "@/record-management/components/Loader/loader"; - -// Define TypeScript interfaces for the props -interface PositionDetailItemProps { - icon: React.ReactNode; - label: string; - value: string; -} - -interface InfoBoxProps { - icon: React.ReactNode; - label: string; - value: string; - color?: "blue" | "purple" | "green" | "gray"; - fullWidth?: boolean; - amharic?: boolean; - capitalize?: boolean; -} - -interface ProfileInfoItemProps { - icon: React.ReactNode; - label: string; - value: string; - capitalize?: boolean; -} - -interface SecurityItemProps { - title: string; - description: string; - action: React.ReactNode; - status?: "secure" | "warning" | "inactive"; - - twoFactorStatus?: "on" | "off"; -} - -interface ActivityItemProps { - icon: React.ReactNode; - title: string; - time: string; - type: "success" | "info" | "warning"; -} - -const ProfilePage = () => { - const navigate = useNavigate(); - const { t } = useTranslation(); - const queryClient = useQueryClient(); - - const { - userDetails, - isLoading, - isError, - refetch, - logout, - setTwoFactorAuth, - twoFactorData, - editTwoFA, - isLoadingStatus, - } = useAuthUser(); - const userRoles = userDetails?.roles?.map((r: { key: string }) => r.key) || []; - const showUserManagementShortcut = - userRoles.includes("admin") || - userRoles.includes("unit_admin") || - userRoles.includes("super_admin"); - const localizedName = useLocalizedName(); - const [isSessionsExpanded, setIsSessionsExpanded] = useState(false); - const [twoFactor, setTwoFactor] = useState(false); - const [permissionsExpanded, setPermissionsExpanded] = useState(false); - const PERMISSIONS_INITIAL_SHOW = 4; - useEffect(() => { - if (!userDetails) return; - if (twoFactorData?.[0]) { - setTwoFactor(twoFactorData?.[0].isMFARequired); - } - }, [twoFactorData, userDetails]); - - const { - data: sessionsQuery, - deleteSession, - deleteAllSessions, - } = useSessions({ - skip: 0, - take: 10, - orderBy: "CreatedAt:DESC", - }); - const userSessions = sessionsQuery?.sessions || []; - const handleLogoutSession = (sessionId: string) => { - deleteSession(sessionId); - queryClient.invalidateQueries({ queryKey: ["my-sessions"] }); - }; - const handleLogoutAllSessions = () => { - const otherSessions = userSessions.filter((s) => s.id); - - if (otherSessions.length > 0) { - deleteAllSessions({ sessionIds: otherSessions.map((s) => s.id) }); - queryClient.invalidateQueries({ queryKey: ["my-sessions"] }); - } - }; - const handleFactorAuthentication = () => { - if (isLoading || isLoadingStatus) return; // prevent duplicate clicks - if (!userDetails) return; - - const item = twoFactorData?.[0]; - const hasExistingRecord = !!item?.id; - if (!hasExistingRecord) { - // ✅ POST only if no record exists at all - setTwoFactorAuth({ isMFARequired: true }); - } else { - // Toggle using PUT/edit if a record already exists - const newStatus = !twoFactor; - editTwoFA({ id: item.id, isEnabled: newStatus }); - } - }; - - const handleLogout = () => { - logout(); - }; - - if (isLoading) { - return ; - } - - if (isError || !userDetails) { - return ( -
- -
- - - -

- {t("profile.error")} -

-
- - - -
-
- ); - } - - const employeeData = userDetails.employee?.[0] || {}; - const position = employeeData.positions?.[0] || {}; - const positionIsDeletation = employeeData.positions.filter( - (item: any) => item.isDelegate - ); - const positionHasDeletation = employeeData.positions.filter( - (item: any) => item.hasDelegated - ); - // Animation variants - const container = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const item = { - hidden: { opacity: 0, y: 20 }, - show: { opacity: 1, y: 0 }, - }; - - // New reusable components for the improved layout - const PositionDetailItem: React.FC = ({ - icon, - label, - value, - }) => ( - -
- {icon} -
-
-

- {label} -

-

- {value} -

-
-
- ); - - const InfoBox: React.FC = ({ - icon, - label, - value, - color = "gray", - fullWidth = false, - amharic = false, - capitalize = false, - }) => ( - -
-
- {icon} -
-

- {label} -

-
-

- {value || "Not specified"} -

-
- ); - - // Reusable Component: ProfileInfoItem - const ProfileInfoItem: React.FC = ({ - icon, - label, - value, - capitalize = false, - }) => ( - -
- {icon} -
-
-

{label}

-

- {value} -

-
-
- ); - - // Reusable Component: SecurityItem - const SecurityItem: React.FC = ({ - title, - description, - status, - action, - twoFactorStatus, - }) => ( - -
-
-
-

- {title} -

-

- {description} -

-
-
- {action} -
- ); - - // Reusable Component: ActivityItem - const ActivityItem: React.FC = ({ - icon, - title, - time, - type, - }) => ( - -
- {icon} -
-
-

{title}

-

{time}

-
-
- ); - - return ( -
- -
- {/* Enhanced Header */} - -
- - - - -
- - -
- - {/* User Quick Stats */} - -
-
- - Online - -
-
- - {t(`profile.userTypes.${userDetails.userType}`)} - -
-
- - {/* Main Content Grid - Reorganized */} - - {/* Left Sidebar - Profile & Account Status */} -
- {/* Enhanced Profile Card */} - - - {/* Profile Header with Gradient */} -
-
-
- - {userDetails.status === "accepted" - ? "Verified" - : "Pending"} - -
- - {/* Profile Avatar */} -
- -
- -
-
-
-
-
- - - {/* User Info */} -
- - {localizedName(userDetails.name) || t("profile.noName")} - -
- - @{userDetails.username} -
-
- - {/* Status Indicators */} -
- -
- {positionIsDeletation ? "Yes" : "No"} -
-
Delegate
-
- -
- {position.permissions?.length || 0} -
-
Permissions
-
-
- - {/* Profile Details */} -
- } - label={t("profile.email")} - value={userDetails.email} - /> - } - label={t("profile.userType")} - value={t(`profile.userTypes.${userDetails.userType}`)} - capitalize - /> - } - label={t("profile.passwordSet")} - value={ - userDetails.hasSetPassword - ? t("common.yes") - : t("common.no") - } - /> -
- - {/* Action Buttons */} -
- - - - - - -
-
-
-
- - {/* Account Status Card */} - - - - - - Account Status - - - - {/* Verification Status */} -
-
-
- {userDetails.status === "accepted" ? ( - - ) : ( - - )} -
-
-

- Account Status -

-

- {userDetails.status === "accepted" - ? "Verified and active" - : "Pending approval"} -

-
-
- - {userDetails.status === "accepted" ? "Active" : "Pending"} - -
- - {/* Password Status */} -
-
-
- -
-
-

- Password -

-

- {userDetails.hasSetPassword - ? "Secured with password" - : "No password set"} -

-
-
- - {userDetails.hasSetPassword ? "Set" : "Not Set"} - -
- - {/* Quick Action */} -
- - - -
-
-
-
-
- - {/* Right Column - Main Content */} -
- - - - -
- -
-
- Employee Information -

- Personal & organizational details -

-
-
-
- -
- {/* Basic Info */} -
- } - label="Employee ID" - value={employeeData?.id || "Not specified"} - color="blue" - /> - } - label="Organization ID" - value={employeeData?.organizationId || "Not specified"} - color="purple" - /> - } - label="User Type" - value={userDetails.userType} - color="green" - capitalize - /> -
- - {/* Names */} -
- } - label="English Name" - value={employeeData?.name?.en || "Not specified"} - fullWidth - /> - } - label="Amharic Name" - value={employeeData?.name?.am || "Not specified"} - fullWidth - amharic - /> -
- - {/* Position Summary */} - {userDetails.employee[0]?.positions?.[0] && ( -
-

- - Current Position -

-
-
-

- Position -

-

- {userDetails.employee[0].positions[0].name?.en || - "Not specified"} -

-
-
-

- Delegation -

- - {positionHasDeletation.length ? "Active" : "None"} - -
-
-
- )} -
-
-
-
- {/* Top Row: Position Details & Security */} -
- {/* Position Details */} - - - - -
- -
-
- {t("profile.positionDetails")} -

- Your current role -

-
-
-
- -
- } - label={t("profile.positionName")} - value={localizedName(position.name) || t("common.na")} - /> - } - label={t("profile.positionKey")} - value={position.key || t("common.na")} - /> - } - label={t("profile.employeeId")} - value={employeeData.id || t("common.na")} - /> -
-
-
-
- - {/* Security Card */} - - - - -
- -
-
- {t("profile.accountSecurity")} -

- Account safety -

-
-
-
- -
- - - - } - /> - - - -
- } - /> -
-
-
- - - {t("profile.sessions")} - - {userSessions.length > 0 && ( - - {userSessions.length} active - - )} -
- -
- - {/* Expandable Sessions Content */} - {isSessionsExpanded && ( - -
- {userSessions.length === 0 ? ( -
- -

No active sessions

-
- ) : ( - <> - {userSessions.map((session) => ( -
-
-
- -
-
-

- Device: {session.device} -

-

- Email: {session.email} -

-

- Created:{" "} - {new Date( - session.createdAt - ).toLocaleString()} -

-
-
- -
- ))} - - - - )} -
-
- )} -
-
- - - -
- - {/* Middle Row: Permissions */} - - - - -
- -
-
- {t("profile.permissions")} -

- Access rights and privileges -

-
-
-
- - {position.permissions?.length > 0 ? ( - - {(permissionsExpanded ? position.permissions : position.permissions.slice(0, PERMISSIONS_INITIAL_SHOW)).map((perm: any, index: number) => ( - -
- -
- - {perm.key} - -
- ))} - {position.permissions.length > PERMISSIONS_INITIAL_SHOW && ( - - )} -
- ) : ( -
-
- -
-

- {t("profile.noPermissions")} -

-

- No special access rights assigned -

-
- )} -
-
-
-
- -
-
- ); -}; - -export default ProfilePage; diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index 71afbee2d..82e716d66 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -17,7 +17,6 @@ import { ClipboardList, Clock, FileText, - Home, Languages, Key, LogOut, @@ -256,10 +255,28 @@ const Top: React.FC = ({ }; return ( -
-
-
-
+
+
+
+ {onToggleSidebar ? ( + + + + + + Toggle sidebar + + + ) : ( @@ -320,191 +337,183 @@ const Top: React.FC = ({ ))} + )} -
- -
- - {showUserManagementShortcut && ( - - )} +
+
-
+ {showUserManagementShortcut && ( + + )} +
+ +
+ + + {canActivateUsers && ( +
+ + + {pendingUsersCount > 0 && ( + + {pendingUsersCount > 99 ? "99+" : pendingUsersCount} + + )} + + {openPendingUsers && ( +
+ +
+ )} +
+ )} + +
- {canActivateUsers && ( -
- - - {pendingUsersCount > 0 && ( - - {pendingUsersCount > 99 ? "99+" : pendingUsersCount} - - )} - - {openPendingUsers && ( -
- -
- )} + {openNotifications && ( +
+
)} +
-
- + {openReminders && ( +
+ +
+ )} +
+ + + + + + + + {UI_LANGUAGE_OPTIONS.map((lang) => ( + changeLanguage(lang.value)} className={cn( - "h-4 w-4 transition-colors", - openNotifications && - "fill-primary-100 text-primary-700 dark:fill-primary-900/40 dark:text-primary-300", + "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", + currentLanguage === lang.value && + "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", )} - /> - {unseenCount > 0 && ( - - {unseenCount > 99 ? "99+" : unseenCount} - - )} - + > +
+ + {getUiLanguageShortLabel(lang.value, t)} + + {getUiLanguageLabel(lang.value, t)} +
+ {currentLanguage === lang.value && ( + + )} +
+ ))} +
+
- {openNotifications && ( -
- -
- )} -
- - {/* Reminders */} -
+ + - {openReminders && ( -
- -
- )} -
- - - - - - - - {UI_LANGUAGE_OPTIONS.map((lang) => ( - changeLanguage(lang.value)} - className={cn( - "flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/30", - currentLanguage === lang.value && - "bg-primary-100 text-primary-800 dark:bg-primary-800/50 dark:text-white", - )} - > -
- - {getUiLanguageShortLabel(lang.value, t)} - - {getUiLanguageLabel(lang.value, t)} -
- {currentLanguage === lang.value && ( - - )} -
- ))} -
-
- - - - - - - -
-

{fullName}

-

- {roleLabel} -

+ > + {initials.toUpperCase()}
+
+ + {fullName} + + + {roleLabel} + +
+ + + + +
+

{fullName}

+

+ {roleLabel} +

+
+ + navigate("/profile")} + > + + {t("header.viewProfile")} + + + navigate("/update-profile")} + > + + {t("header.editProfile")} + + + navigate("/change-password")} + > + + {t("header.changePassword")} + + + {showRecordManagementShortcut && ( navigate("/profile")} + onClick={() => navigate("/record-management/dashboard")} > - - {t("header.viewProfile")} + + {t("nav.Record Management")} + )} + {showUserManagementShortcut && ( navigate("/update-profile")} + onClick={() => navigate("/user-management")} > - - {t("header.editProfile")} + + + {t("dashboard.userManagement", "User Management")} + + )} - navigate("/change-password")} - > - - {t("header.changePassword")} - + - {showRecordManagementShortcut && ( - navigate("/record-management/dashboard")} - > - - {t("nav.Record Management")} - - )} - - {showUserManagementShortcut && ( - navigate("/user-management")} - > - - - {t("dashboard.userManagement", "User Management")} - - - )} - - - - - - {t("header.signOut")} - -
-
-
+ + + {t("header.signOut")} + + +
-
-
+
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx index 25f8700e8..ade96250d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx @@ -1,84 +1,92 @@ -import { Link, Outlet, useLocation } from "react-router-dom"; -import { AppMenuTabs } from "./AppMenuTabs"; -import { - Sidebar, - SidebarContent, - SidebarHeader, - SidebarInset, - SidebarRail, - SidebarTrigger, -} from "@/shared/common/ui/sidebar"; -import Top from "@/record-management/components/common/Top"; -import { useAuth } from "@/shared/context/AuthContext"; - -export const AppLayout = () => { - const { pathname } = useLocation(); - const isAuthPage = pathname === "/"; - const { user } = useAuth(); - - const userRoles = user?.roles?.map((role) => role.key) || []; - const isSuperAdmin = userRoles.includes("super_admin"); - - // Standalone full-page scroll container for the auth screen — the host - // chrome is `overflow: hidden`, so this subtree must scroll itself. - if (isAuthPage) { - return ( -
- -
- ); - } - - return ( - <> - {/* is a shared component rendered as a full-width `fixed` 64px bar, - so the sidebar is offset to start beneath it (top-16) rather than - owning the top-left corner. Collapses to an icon rail on desktop; on - mobile it's a Sheet drawer opened by the below (Top's - own burger is a module-nav dropdown, not the sidebar toggle). */} - - - - EDR -
- - User Management - - - EDR Freight - -
- -
- - - - -
- - - - {/* Top provides its own in-flow h-24 spacer clearing the fixed header. */} - {/* Mobile-only drawer opener — desktop shows the sidebar/rail directly. */} -
- - - Menu - -
-
-
- -
-
-
- - ); -}; +import { Link, Outlet, useLocation } from "react-router-dom"; +import { ArrowLeft } from "lucide-react"; +import { AppMenuTabs } from "./AppMenuTabs"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarRail, + useSidebar, +} from "@/shared/common/ui/sidebar"; +import Top from "@/record-management/components/common/Top"; +import { useAuth } from "@/shared/context/AuthContext"; + +export const AppLayout = () => { + const { pathname } = useLocation(); + const isAuthPage = pathname === "/"; + const { toggleSidebar } = useSidebar(); + const { user } = useAuth(); + + const userRoles = user?.roles?.map((role) => role.key) || []; + const isSuperAdmin = userRoles.includes("super_admin"); + + // Standalone full-page scroll container for the auth screen — the host + // chrome is `overflow: hidden`, so this subtree must scroll itself. + if (isAuthPage) { + return ( +
+ +
+ ); + } + + return ( + <> + {/* Full-height sidebar owning the left column (back-to-home + brand at the + top). lives inside , to the right of the sidebar, + and is `sticky` — so it never overlaps the sidebar and re-flows when + the sidebar collapses to its icon rail. Top's burger (onToggleSidebar) + drives collapse on desktop and the Sheet drawer on mobile. */} + + + + + + Back to home + + + + EDR +
+ + User Management + + + EDR Freight + +
+ +
+ + + + +
+ + {/* Internal scroll container: the sidebar stays fixed and full-height + while this column scrolls beneath the sticky bar. */} + + +
+
+ +
+
+
+ + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index 9af344caf..d15d82799 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -210,7 +210,7 @@ export default function PositionManagement() { return (
- + {t("contentManagement.permissionType")} diff --git a/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx b/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx index 57d7a1898..a052caa11 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/pages/position-management/index.tsx @@ -1,13 +1,7 @@ import PositionManagement from "@/user-management/components/position-management/PositionLists"; -import { t } from "i18next"; const PositionManagementPage = () => { - return ( -
-

{t("contentManagement.permissionManagement")}

- -
- ); + return ; }; export default PositionManagementPage; From ec066f3d29a76c3e46f7419668008df344d5be09 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 13:13:14 +0000 Subject: [PATCH 04/37] Fix: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added assertCapacity() checks before saving (matches single receive) Added applyCapacityDelta() after save to increment counters Now validates warehouse → yard → zone capacity hierarchy Single receive already had both checks; bulk receive was gap. --- ...80000000000-AddMaintenanceDueNotifiedAt.ts | 22 ++++ .../modules/bookings/bookings.controller.ts | 12 ++- .../src/modules/bookings/bookings.service.ts | 22 +++- .../entities/maintenance-schedule.entity.ts | 4 + .../maintenance/maintenance.controller.ts | 7 ++ .../modules/maintenance/maintenance.module.ts | 2 + .../maintenance/maintenance.repository.ts | 101 ++++++++++++++++++ .../maintenance/maintenance.service.ts | 43 +++++++- .../warehouses/ReleaseOrderModal.tsx | 16 ++- .../CustomerTruckAssignmentCard.tsx | 76 ++++++++++--- .../portal/src/services/api.ts | 4 +- .../portal/src/services/bookings.service.ts | 4 +- 12 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts diff --git a/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts new file mode 100644 index 000000000..61431c5bc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Dedup stamp for the km/date-due maintenance alert — without it the daily + * cron would re-notify every day a schedule stays due. + */ +export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface { + name = 'AddMaintenanceDueNotifiedAt2480000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules + ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ac5b3c3cd..c93c61f13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -436,18 +436,26 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') - @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + @ApiOperation({ + summary: + 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, + @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } + const extraCopyIndexes = (copies ?? '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id); + await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e95cd65c9..da324786d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -142,8 +142,21 @@ export class BookingsService { return this.findById(bookingId); } + /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ + static readonly FREIGHT_ORDER_EXTRA_COPIES = [ + 'Original 1 (for Issuing Carrier)', + 'Original 2 (for Consignee)', + 'Original 3 (for Shipper)', + 'Copy 4 (Delivery Receipt)', + 'Copy 5 (Extra Copy)', + 'Copy 6 (Extra Copy)', + 'Copy 7 (Extra Copy)', + 'Copy 8 (for Agent)', + ] as const; + async customerTruckFreightOrderCopies( bookingId: string, + extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { @@ -171,7 +184,12 @@ export class BookingsService { [bookingId], ); - const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + // The 2 gate copies are ALWAYS printed; the waybill-style copies are + // whatever the customer ticked (indexes into the fixed catalog). + const extraCopies = [...new Set(extraCopyIndexes)] + .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) + .filter(Boolean); + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { @@ -268,6 +286,7 @@ export class BookingsService { arrivedAt: string | null; containers: string | null; }>, + extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt @@ -386,6 +405,7 @@ export class BookingsService { ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} + ${extraCopies.map((label) => copy(label)).join('')} `; } diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts index a4d4d60a0..7d3ab7a95 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -62,4 +62,8 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) nextDueDate?: Date; + + /** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */ + @Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true }) + dueNotifiedAt?: Date; } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index 4ad8026f2..9324ad694 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -44,6 +44,13 @@ export class MaintenanceController { return this.maintenanceService.updateMaintenanceSchedule(id, dto); } + @Get('due-board') + @BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view]) + @ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' }) + async getDueBoard() { + return this.maintenanceService.getDueBoard(); + } + @Get('upcoming/:vehicleId') @BookingStaff(FREIGHT_PERMS.maintenance.view) @ApiOperation({ summary: 'Get upcoming maintenance' }) diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index 8f4fe1d0b..5287948a7 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -12,10 +12,12 @@ import { WorkOrderRepository } from './work-order.repository'; import { PartRepository } from './part.repository'; import { WarrantyRepository } from './warranty.repository'; import { MaintenanceController } from './maintenance.controller'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; @Module({ imports: [ TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + NotificationInboxModule, ], providers: [ MaintenanceService, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 9e8cf972e..8e58702ac 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -47,4 +47,105 @@ export class MaintenanceRepository extends BaseRepository { .getRawOne(); return result?.total || 0; } + + /** + * Fleet-wide "next due" board: one row per vehicle with a SCHEDULED + * maintenance item, driven by time AND km — whichever is soonest. Current km + * is the vehicle's latest fuel-up odometer reading (how mileage is actually + * captured today), falling back to vehicle.actual_distance_km when the + * vehicle has no fuel purchase on file yet. + */ + async getDueBoard(): Promise< + Array<{ + scheduleId: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + scheduledDate: Date; + nextDueDate: Date | null; + nextDueKm: number | null; + currentKm: number | null; + kmRemaining: number | null; + daysRemaining: number | null; + overdue: boolean; + }> + > { + return this.scheduleRepository.manager.query(` + SELECT DISTINCT ON (s.vehicle_id) + s.id AS "scheduleId", + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.description, + s.scheduled_date AS "scheduledDate", + s.next_due_date AS "nextDueDate", + s.next_due_km AS "nextDueKm", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm", + CASE WHEN s.next_due_km IS NOT NULL + THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0) + ELSE NULL END AS "kmRemaining", + CASE WHEN s.next_due_date IS NOT NULL + THEN EXTRACT(DAY FROM s.next_due_date - now()) + ELSE NULL END AS "daysRemaining", + ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) AS overdue + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL + ORDER BY s.vehicle_id, s.scheduled_date ASC + `); + } + + /** + * SCHEDULED items that have crossed their km or date due-point and have not + * yet been notified. Backs the daily km/date maintenance alert. + */ + async getUnnotifiedDue(): Promise< + Array<{ + id: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + nextDueKm: number | null; + nextDueDate: Date | null; + currentKm: number | null; + }> + > { + return this.scheduleRepository.manager.query(` + SELECT + s.id, + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.description, + s.next_due_km AS "nextDueKm", + s.next_due_date AS "nextDueDate", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm" + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' + AND s.deleted_at IS NULL + AND s.due_notified_at IS NULL + AND ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) + `); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 8ef4800b6..7bf83647a 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -1,14 +1,19 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() export class MaintenanceService { + private readonly logger = new Logger(MaintenanceService.name); + constructor( private readonly maintenanceRepository: MaintenanceRepository, @InjectRepository(MaintenanceSchedule) @@ -18,8 +23,44 @@ export class MaintenanceService { // Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we // reach it through the global DataSource rather than @InjectRepository. private readonly dataSource: DataSource, + private readonly inbox: NotificationInboxService, ) {} + /** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */ + async getDueBoard() { + return this.maintenanceRepository.getDueBoard(); + } + + /** + * Daily check: a vehicle's driven km (latest fuel-up odometer reading, since + * that's the only place mileage is actually recorded) or its due date has + * reached a SCHEDULED item's threshold → alert backoffice once. + */ + @Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' }) + async sendDueAlerts(): Promise { + try { + const due = await this.maintenanceRepository.getUnnotifiedDue(); + for (const item of due) { + const reason = + item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm + ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` + : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title: `Maintenance due — ${item.plateNumber}`, + body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`, + link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`, + data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' }, + }); + await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() }); + } + } catch (err) { + this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack); + } + } + /** * Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle * under maintenance is taken out of service (MAINTENANCE + BUSY); once the diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 681f12ad5..7633e4dcd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Last-mile · ${truckPrefill.truckPlateNumber}`, trailerPlate: truckPrefill.trailerPlateNumber ?? '', driverName: truckPrefill.driverName ?? '', + driverLicense: truckPrefill.driverLicense ?? '', driverPhone: truckPrefill.driverPhone ?? '', truckType: truckPrefill.truckType ?? '', containerNumbers: splitContainerNumbers(truckPrefill.containerNumber), @@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Customer · ${t.plateNumber} — ${t.driverName}`, trailerPlate: '', driverName: t.driverName, + driverLicense: '', driverPhone: '', truckType: t.truckType, containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean), @@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`, trailerPlate: t.trailerPlateNumber ?? '', driverName: t.driverName ?? '', + driverLicense: t.driverLicense ?? '', driverPhone: t.driverPhone ?? '', truckType: t.truckType ?? '', containerNumbers: splitContainerNumbers(t.containerNumber), @@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea // way. A walk-in truck (typed plate, no assignment) stays editable at arrival. const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption); const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName); + // The freight order's truck details are the customer's / fleet's record — the + // gate may FILL blanks (walk-in license, phone) but never edit shown values. + const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate); + const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense); + const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone); const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0; /** Load a truck into the form: its saved block if any, else its assignment. */ @@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setTruckPlateNumber(plate); setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || ''); setDriverName(block?.driverName || option?.driverName || ''); - setDriverLicense(block?.driverLicense || ''); + setDriverLicense(block?.driverLicense || option?.driverLicense || ''); setDriverPhone(block?.driverPhone || option?.driverPhone || ''); setTruckType(block?.truckType || option?.truckType || ''); const loaded = block @@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea label="Trailer plate number" value={trailerPlateNumber} onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)} - readOnly={isEntranceLocked} + readOnly={isTrailerLocked} /> setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} /> - setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} /> - setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} /> setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 5d2bac886..8d18a42ab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -3,6 +3,7 @@ import { Alert, Badge, Button, + Checkbox, Divider, Group, Loader, @@ -27,6 +28,19 @@ import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; +// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate +// copies (Port Operations, Gate Security & Carrier) are always printed. +const FREIGHT_ORDER_COPIES = [ + { index: 1, label: "Original 1 (for Issuing Carrier)" }, + { index: 2, label: "Original 2 (for Consignee)" }, + { index: 3, label: "Original 3 (for Shipper)" }, + { index: 4, label: "Copy 4 (Delivery Receipt)" }, + { index: 5, label: "Copy 5 (Extra Copy)" }, + { index: 6, label: "Copy 6 (Extra Copy)" }, + { index: 7, label: "Copy 7 (Extra Copy)" }, + { index: 8, label: "Copy 8 (for Agent)" }, +]; + const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -138,8 +152,11 @@ export function CustomerTruckAssignmentCard({ }); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); + const [selectedCopies, setSelectedCopies] = useState( + FREIGHT_ORDER_COPIES.map((c) => c.index), + ); const downloadFreightOrder = async () => { - const blob = await downloadMutation.mutateAsync({ id: booking.id }); + const blob = await downloadMutation.mutateAsync({ id: booking.id, copies: selectedCopies }); downloadBlob(blob, `freight-order-${booking.reference}.pdf`); }; @@ -322,17 +339,52 @@ export function CustomerTruckAssignmentCard({ )} {trucks.length > 0 && ( - - - + + Copies + + + + + + {FREIGHT_ORDER_COPIES.map((c) => ( + + setSelectedCopies((prev) => + e.currentTarget.checked + ? [...prev, c.index].sort((a, b) => a - b) + : prev.filter((i) => i !== c.index), + ) + } + /> + ))} + + + + Port Operations and Gate Security copies are always included. + + + + )} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 68276a18c..6fd1bd479 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -308,10 +308,10 @@ export const api = { bookingsService.assignCustomerTruck(id, payload), ), - downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>( + downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>( "bookings", "downloadCustomerTruckFreightOrder", - ({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id), + ({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies), ), downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index fbb84e1c0..58f3b330b 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -212,10 +212,10 @@ export const bookingsService = { ); return data.data; }, - downloadCustomerTruckFreightOrder: async (id: string): Promise => { + downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise => { const { data } = await client.get( `/api/bookings/${id}/customer-truck-assignment/freight-order`, - { responseType: "blob" }, + { responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined }, ); return data; }, From e5be205bd85d16bc9b264fada6d73b604da5a635 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 16:18:58 +0300 Subject: [PATCH 05/37] Informational updates --- .../backoffice/src/app/dashboard/page.tsx | 25 +++++++++++++-- .../backoffice/src/app/tickets/page.tsx | 31 +++++++++++++------ .../portal/src/app/booking/detail/page.tsx | 2 ++ .../portal/src/app/booking/review/page.tsx | 9 ------ .../portal/src/app/packages/[id]/page.tsx | 2 +- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index b248fd68a..7eede0089 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -234,7 +234,7 @@ function DashboardPageContent() { )} {/* Stat cards */} -
+
@@ -257,7 +257,28 @@ function DashboardPageContent() { }, ]} /> - {/* Tickets card hidden temporarily */} + + } + iconBg="bg-emerald-100 dark:bg-emerald-900/30" + label="Tickets" + total={stats?.totalTickets ?? 0} + loading={statsLoading} + href="/tickets" + rows={[ + { + label: "Regular", + value: stats?.totalNormalTickets ?? 0, + href: "/tickets", + }, + { + label: "Package", + value: stats?.totalPackageTickets ?? 0, + href: "/tickets", + }, + ]} + /> {/* Revenue card */}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 5c6354fd4..bd12def01 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -10,7 +10,7 @@ import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import Image from 'next/image'; -import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; +import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -100,10 +100,21 @@ export default function TicketsPage() { const [generateMissingResult, setGenerateMissingResult] = useState(null); + const { data: hasMissingTickets } = useQuery({ + queryKey: ['bookings-missing-tickets'], + queryFn: async () => { + const res = await bookingsApi.getAll({ status: 'CONFIRMED', paymentStatus: 'SUCCEEDED', take: 50 }); + const items: any[] = (res as any)?.items ?? (Array.isArray(res) ? res : []); + return items.some((b: any) => !b.tickets?.length); + }, + refetchInterval: 30_000, + }); + const generateMissingMutation = useMutation({ mutationFn: () => ticketsApi.generateMissing(), onSuccess: (result: any) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); + queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); setGenerateMissingResult(result); setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`); setTimeout(() => setSuccessMessage(''), 6000); @@ -562,14 +573,16 @@ export default function TicketsPage() {

Manage tickets and validations

- generateMissingMutation.mutate()} - > - Generate Missing - + {hasMissingTickets && ( + generateMissingMutation.mutate()} + > + Generate Missing + + )} setExportModalOpen(true)}>Export
diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 3ac0efd56..67df46686 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -128,6 +128,7 @@ function BookingDetailContent() { ) { apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {}); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [booking?.id, booking?.status, booking?.tickets?.length]); const { data: paymentMethods } = useQuery({ @@ -151,6 +152,7 @@ function BookingDetailContent() { if (intentStatus?.status === "SUCCEEDED") { refetch(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [intentStatus?.status]); const selectedPaymentMethod = diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index ac90fee9c..789cf20a6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -974,9 +974,6 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => {(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}

- {(p as any).outboundSeatId && ( -

{formatSeatClass(outboundSchedule)}

- )}

Return

@@ -984,9 +981,6 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} — } {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}

- {(p as any).inboundSeatId && ( -

{formatSeatClass(inboundSchedule)}

- )}
) : ( @@ -996,9 +990,6 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => {p.coachNumber && {p.coachNumber} — } {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}

- {p.seatId && ( -

{formatSeatClass(selectedSchedule)}

- )}
)}
diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 361090b25..d71794345 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -783,7 +783,7 @@ export default function PackageDetailPage() {

Available

- {availableSeats} seats + {pkg.priceTiers.filter((t) => t.availableSeats > 0).length} classes

From 62fcf6a8d2bdc8508954191dd45adc4fdca1b145 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 16:31:03 +0300 Subject: [PATCH 06/37] Passenger portal build issue resolution --- .../portal/src/app/booking/confirmation/page.tsx | 14 ++++---------- .../portal/src/app/booking/review/page.tsx | 8 -------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 19bae536b..d60918752 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -1,6 +1,7 @@ "use client"; export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; @@ -105,15 +106,8 @@ export default function ConfirmationPage() { // common) as normal pending state forever, since a caught error that returns data // looks like a success to React Query and never gets retried. queryFn: (): Promise => apiClient.get(`/bookings/${bookingId}`), - // Payment status must never be served from a stale cache — the app-wide default - // (providers.tsx) is a 60s staleTime, which would otherwise block React Query's - // own refetch-on-window-focus from firing (it only refetches stale data). Without - // this override, a tab left open past a payment completing can sit showing - // "pending" long after it's actually confirmed, even after being refocused, - // until the interval below happens to tick — which browsers throttle heavily in - // backgrounded tabs, so that can take a very long time. staleTime: 0, - enabled: !!bookingId, + enabled: !!bookingId && typeof window !== 'undefined', // Keep polling after CONFIRMED until tickets are issued — ticket generation runs // async after the booking transaction commits (see finalizePaymentSuccess in // payments.service.ts), so the first CONFIRMED fetch often returns an empty @@ -143,7 +137,7 @@ export default function ConfirmationPage() { const { data: intentStatus } = useQuery({ queryKey: ["payment-intent-status", bookingId], queryFn: () => apiClient.get(`/payments/intents/${bookingId}`), - enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId, + enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId && typeof window !== 'undefined', staleTime: 0, refetchInterval: () => Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS @@ -155,7 +149,7 @@ export default function ConfirmationPage() { if (intentStatus?.status === "SUCCEEDED") { refetchBooking(); } - }, [intentStatus?.status]); + }, [intentStatus?.status, refetchBooking]); // Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge — // a gateway redirect back here does not mean payment succeeded (see payment return pages). diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 789cf20a6..4b9148001 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -94,14 +94,6 @@ export default function ReviewPage() { return suffix ? `${base}${suffix}` : base; }; - // The seat class/category is chosen once per leg (coach type selected on /booking/seats), - // so every seat on that leg shares it — no need to look it up per-seat. - const formatSeatClass = (schedule: any): string => { - const raw = schedule?.seatClassName; - if (!raw) return ''; - return String(raw).replace(/_/g, ' '); - }; - useEffect(() => { const fetchSeatDetails = async () => { try { From 15540f78e21126ccf57ab4648c60f76eae82d322 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 13:34:53 +0000 Subject: [PATCH 07/37] fix(portal): Table has no size prop in BulkTruckUploadModal --- .../maintenance/maintenance-due-alert.spec.ts | 76 +++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../src/pages/fleet/MaintenancePage.tsx | 81 +++++++++++++++++++ .../components/BulkTruckUploadModal.tsx | 2 +- 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts new file mode 100644 index 000000000..7aac5e863 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -0,0 +1,76 @@ +import { NotificationAudience } from '@edr/types'; + +import { MaintenanceService } from './maintenance.service'; + +/** + * The daily due-alert: a SCHEDULED item that crossed its km or date threshold + * gets one BACKOFFICE notification, then is stamped so it isn't repeated. + */ +function makeService(due: Array>) { + const update = jest.fn(); + const notify = jest.fn(); + const service = Object.create(MaintenanceService.prototype) as Record; + service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) }; + service.scheduleRepository = { update }; + service.inbox = { notify }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, update, notify }; +} + +describe('MaintenanceService.sendDueAlerts', () => { + it('reports the km reason when the km threshold was crossed', async () => { + const { service, notify, update } = makeService([ + { + id: 'sched-1', + vehicleId: 'v-1', + plateNumber: 'ET-9875', + maintenanceType: 'PREVENTIVE', + description: 'Oil change', + nextDueKm: 50000, + nextDueDate: null, + currentKm: 50200, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + audience: NotificationAudience.BACKOFFICE, + title: 'Maintenance due — ET-9875', + body: expect.stringContaining('driven 50200 km (due at 50000 km)'), + }), + ); + expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) }); + }); + + it('reports the date reason when only the due date has passed', async () => { + const { service, notify } = makeService([ + { + id: 'sched-2', + vehicleId: 'v-2', + plateNumber: 'AA-8642', + maintenanceType: 'INSPECTION', + description: 'Annual inspection', + nextDueKm: null, + nextDueDate: new Date('2026-01-01'), + currentKm: 1000, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }), + ); + }); + + it('does nothing when nothing is due', async () => { + const { service, notify, update } = makeService([]); + + await service.sendDueAlerts(); + + expect(notify).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2f65e110e..80645d256 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -199,6 +199,7 @@ export const QUERY_KEYS = { MAINTENANCE: { ROOT: ["maintenance"] as const, + dueBoard: () => ["maintenance", "due-board"] as const, schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, upcoming: (vehicleId?: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 3aa2506b5..0fcb35a27 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -35,6 +35,21 @@ interface MaintenanceSchedule { serviceProvider?: string; } +interface DueBoardRow { + scheduleId: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + scheduledDate: string; + nextDueDate: string | null; + nextDueKm: number | null; + currentKm: number | null; + kmRemaining: number | null; + daysRemaining: number | null; + overdue: boolean; +} + const emptyForm = { maintenanceType: 'PREVENTIVE', description: '', @@ -51,6 +66,16 @@ export function MaintenancePage() { const [openScheduleModal, setOpenScheduleModal] = useState(false); const [formData, setFormData] = useState(emptyForm); + // Maintenance is driven by time AND km, not a picked-then-scheduled action — + // this is the fleet-wide board of what's actually due, by date or mileage. + const { data: dueBoard, isLoading: dueLoading } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(), + queryFn: async () => { + const res = await api.get('/maintenance/due-board'); + return (res.data || []) as DueBoardRow[]; + }, + }); + const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { @@ -132,6 +157,62 @@ export function MaintenancePage() { + + + Due Board — by date and driven km + + + {dueLoading ? ( + Loading… + ) : dueBoard && dueBoard.length > 0 ? ( + + + + Vehicle + Type + Next Due Date + Next Due Km + Current Km + Remaining + Status + + + + {dueBoard.map((row) => ( + setSelectedVehicle(row.vehicleId)} + style={{ cursor: 'pointer' }} + > + {row.plateNumber} + {row.maintenanceType} + + {row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'} + + {row.nextDueKm ?? '—'} + {row.currentKm ?? '—'} + + {row.kmRemaining != null + ? `${row.kmRemaining} km` + : row.daysRemaining != null + ? `${row.daysRemaining} d` + : '—'} + + + + {row.overdue ? 'OVERDUE' : 'SCHEDULED'} + + + + ))} + +
+ ) : ( + Nothing scheduled fleet-wide + )} +
+
+