diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1a371cd60..bd702542b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3011,6 +3011,29 @@ export class WarehouseInventoryService { ); } + // A truck leaves with the containers ASSIGNED to it — never another + // truck's. Enforced whenever the truck has an assigned load on file + // (customer self-haul or EDR last-mile). + if (dto.containerNumber && dto.truckPlateNumber?.trim()) { + const selectedNumbers = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + const assigned = await this.truckAssignedContainers( + item.bookingId, + dto.truckPlateNumber.trim(), + ); + if (assigned.length && selectedNumbers.length) { + const foreign = selectedNumbers.filter((n) => !assigned.includes(n)); + if (foreign.length) { + throw new BadRequestException( + `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` + + `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`, + ); + } + } + } + // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. // Skipped when the operator chose not to weigh (containers only). @@ -3653,6 +3676,32 @@ export class WarehouseInventoryService { })); } + /** + * Container numbers assigned to a truck (by plate) on this booking, from both + * haulage paths: customer self-haul (customer_truck_containers) and EDR + * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased. + */ + private async truckAssignedContainers(bookingId: string, plate: string): Promise { + const rows: Array<{ cn: string | null }> = await this.dataSource.query( + `SELECT UPPER(cc.container_number) AS cn + FROM freight.customer_truck_assignments a + JOIN freight.customer_truck_containers cc + ON cc.assignment_id = a.id AND cc.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + UNION + SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`, + [bookingId, plate], + ); + return rows.map((r) => r.cn).filter((n): n is string => Boolean(n)); + } + /** * The booking's containers with their VGM cargo weight (tonnes), keyed by * container number. Drives the truck-leaving exit weighing: the selected diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aed710e2e..f8e17fe76 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -189,6 +189,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + permission: FREIGHT_PERMS.bookings.view, }, ...demoItems, ], @@ -383,26 +384,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Import Overview", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Arrival Queue", href: "/dashboard/arrival-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -416,36 +422,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Export Overview", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loading Queue", href: "/dashboard/loading-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loaded Inventory", href: "/dashboard/loaded-inventory", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Djibouti Unloading", href: "/dashboard/export-djibouti-unloading", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Interchange Documents", href: "/dashboard/interchange-documents", icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -459,6 +472,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity Cargo", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -490,7 +504,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , - permission: FREIGHT_PERMS.warehouseAllocationRules.view, + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], }, { label: "Fee Invoices", @@ -553,7 +570,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , - permission: FREIGHT_PERMS.admin, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ], }, @@ -598,16 +620,27 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; - const itemAllowed = (item: SidebarItem): boolean => { - // GL positions are locked to their single clearance page. - if (etGl) return isEtClearanceItem(item); - if (djGl) return isDjClearanceItem(item); - - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - - return permissionAllowed(item); - }; + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children ? { ...item, children: filterItems(item.children) } : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); // Recursive: a group's own permission gates the whole subtree, leaves are // checked individually, and a group with no surviving children disappears. diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx index d1598403b..4bc98e671 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx @@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight, FileText, Train, Users } from "lucide-react"; import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; + const links = [ { title: "Booking requests", description: "Review and action incoming freight bookings", href: "/dashboard/booking-requests", icon: FileText, + permission: [FREIGHT_PERMS.bookings.view], }, { title: "Train scheduling v2", description: "Full allocation workflow — assign, pin wagons, finalize", href: "/dashboard/operations/train-scheduling-v2", icon: Train, + permission: [FREIGHT_PERMS.trainScheduling.view], }, { title: "Trains", description: "Manage train master data and fleet status", href: "/dashboard/trains", icon: Train, + permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view], }, { title: "User management", description: "Employees, roles, and permissions", href: "/user-management", icon: Users, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ]; export function OverviewQuickLinks() { const navigate = useNavigate(); + const { user } = useAuth(); + + const visible = links.filter((link) => + link.permission.some((key) => hasPermission(user, key)), + ); + if (!visible.length) return null; return ( Quick links - {links.map((link) => { + {visible.map((link) => { const Icon = link.icon; return ( [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); + // A truck may only carry out its OWN assigned containers — when the selected + // truck has an assigned load, other trucks' containers are not offered. + const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase()); // Mantine Selects throw on duplicate option values — legacy bookings can carry // the same container number on two lines, so dedupe defensively. const containerSelectData = [ @@ -390,7 +393,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, ]), ).values(), - ]; + ].filter( + (option) => + assignedLoad.length === 0 || + assignedLoad.includes(option.value.toUpperCase()) || + containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()), + ); const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 0813460f7..c7606641e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -20,12 +20,14 @@ import { } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/auth/useAuth"; import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks"; import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; import "@/components/overview/overview.css"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useOverview } from "@/hooks/useOverview"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import type { OverviewRange, OverviewTabKey } from "@/types/overview"; const TAB_ITEMS: Array<{ @@ -40,6 +42,8 @@ const TAB_ITEMS: Array<{ | "customers" | "staff"; metricKey: string; + /** Any of these keys grants the tab. */ + permission: string[]; }> = [ { value: "bookings", @@ -47,6 +51,7 @@ const TAB_ITEMS: Array<{ icon: FileText, kpiKey: "bookings", metricKey: "totalActive", + permission: [FREIGHT_PERMS.bookings.view], }, { value: "contracts", @@ -54,6 +59,7 @@ const TAB_ITEMS: Array<{ icon: FileSignature, kpiKey: "contracts", metricKey: "totalActive", + permission: [FREIGHT_PERMS.contracts.view], }, { value: "billing", @@ -61,6 +67,7 @@ const TAB_ITEMS: Array<{ icon: Banknote, kpiKey: "billing", metricKey: "successfulPaymentsMtd", + permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], }, { value: "operations", @@ -68,6 +75,12 @@ const TAB_ITEMS: Array<{ icon: Train, kpiKey: "operations", metricKey: "trainsActive", + permission: [ + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.lastMile.view, + ], }, { value: "customers", @@ -75,6 +88,7 @@ const TAB_ITEMS: Array<{ icon: Users, kpiKey: "customers", metricKey: "totalCustomers", + permission: [FREIGHT_PERMS.customers.view], }, { value: "staff", @@ -82,6 +96,12 @@ const TAB_ITEMS: Array<{ icon: UserCheck, kpiKey: "staff", metricKey: "activeEmployees", + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ]; @@ -98,7 +118,19 @@ const OverviewPage = () => { const [range, setRange] = useState("30d"); const [activeTab, setActiveTab] = useState("bookings"); const queryClient = useQueryClient(); - const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range); + const { user } = useAuth(); + const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range); + + // Permission-scoped view: only tabs the user may see; a restricted role + // (e.g. operations) gets a summary 403 — that is not a connection problem. + const visibleTabs = TAB_ITEMS.filter((tab) => + tab.permission.some((key) => hasPermission(user, key)), + ); + const currentTab = visibleTabs.some((t) => t.value === activeTab) + ? activeTab + : visibleTabs[0]?.value; + const accessDenied = + (error as { response?: { status?: number } } | null)?.response?.status === 403; const handleRefresh = () => { void refetch(); @@ -126,7 +158,7 @@ const OverviewPage = () => { /> )} - {isError && ( + {isError && !accessDenied && ( } color="red" @@ -142,48 +174,52 @@ const OverviewPage = () => { )} - setActiveTab((value as OverviewTabKey) ?? "bookings")} - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - {TAB_ITEMS.map((tab) => { - const Icon = tab.icon; - const isActive = activeTab === tab.value; - return ( - } - rightSection={ - summary ? ( - - {getTabBadge(tab)} - - ) : undefined - } - > - {tab.label} - - ); - })} - + {visibleTabs.length > 0 && ( + + setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value) + } + variant="pills" + color="edr-green" + keepMounted={false} + classNames={{ list: "ov-tablist", tab: "ov-tab" }} + > + + {visibleTabs.map((tab) => { + const Icon = tab.icon; + const isActive = currentTab === tab.value; + return ( + } + rightSection={ + summary ? ( + + {getTabBadge(tab)} + + ) : undefined + } + > + {tab.label} + + ); + })} + - {TAB_ITEMS.map((tab) => ( - - - - ))} - + {visibleTabs.map((tab) => ( + + + + ))} + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 69812182f..f30330d96 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -936,6 +936,12 @@ const FirstMilePage = () => { }; const openBulkAssign = () => { + // A single selection has full booking context (details, container list) — + // use the richer single-record flow instead of the blank bulk form. + if (selectedIds.length === 1) { + openAssign(selectedIds[0]); + return; + } setBulkMode(true); setActiveId(null); setVehicleRows([{ vehicleId: null, containerNumber: "" }]); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3f96edd2f..a8edab7ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1066,6 +1066,12 @@ const LastMilePage = () => { }; const openBulkAssign = () => { + // A single selection has full booking context (details, container list) — + // use the richer single-record flow instead of the blank bulk form. + if (selectedIds.length === 1) { + openAssign(selectedIds[0]); + return; + } setBulkMode(true); setActiveId(null); setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);