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 b5df563d9..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). @@ -3347,6 +3370,17 @@ export class WarehouseInventoryService { } } + /** + * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out + * recorded exits but real KG for legacy departTruck rows. Exit papers always + * print tonnes — normalise on read. + */ + // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg); + // migrate the column to tonnes if it ever bites. + private grossAsTons(value: number): number { + return value > 1000 ? Math.round(value) / 1000 : value; + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3406,7 +3440,40 @@ export class WarehouseInventoryService { grossWeightKg: string | number | null; departedAt: string | null; } | null = null; - if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + // The exit-inspection note written at gate-out names the truck doing THIS + // exit — resolve by its plate first. The item's own container may not be on + // the departing truck at all (trucks pick containers freely per trip). + const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)]; + const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null; + if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = a.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at + LIMIT 1`, + [row.bookingId, exitPlate], + ); + truck = truckRow ?? null; + } + if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", @@ -3436,6 +3503,26 @@ export class WarehouseInventoryService { ); truck = truckRow ?? null; } + // Bulk self-haul (no container to match) or an unmatched container: the exit + // paper is still PER TRUCK — use the latest departed customer truck and its + // weighed gross, never the booking's declared total. + if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + NULL AS "containerNumbers", + a.net_weight_tons AS "truckWeightTons" + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL + ORDER BY a.departed_at DESC + LIMIT 1`, + [row.bookingId], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -3450,7 +3537,9 @@ export class WarehouseInventoryService { customerName: row?.customerName ?? null, freightType: row?.freightType ?? null, tradeDirection: row?.tradeDirection ?? null, - containerNumber: row?.containerNumber ?? null, + // Per-truck exit: list every container leaving on THIS truck, not just + // the inventory item's own container. + containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, quantity: Number(row?.quantity ?? 0), weight: Number(row?.weight ?? 0), @@ -3464,12 +3553,12 @@ export class WarehouseInventoryService { truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, truckGateOut: truck?.departedAt ?? null, - // Prefer the weighed gross captured on departure; fall back to the summed - // container VGM when the truck hasn't been weighed yet. - truckWeightKg: truck - ? Number(truck.grossWeightKg ?? 0) > 0 - ? Number(truck.grossWeightKg) - : Number(truck.truckWeightTons ?? 0) * 1000 + // Per-truck load in tonnes: the summed VGM of the containers on this truck + // (recorded net for bulk); the weighed gross only as fallback. + truckWeightTons: truck + ? Number(truck.truckWeightTons ?? 0) > 0 + ? Number(truck.truckWeightTons) + : this.grossAsTons(Number(truck.grossWeightKg ?? 0)) : null, }); @@ -3587,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 @@ -3642,10 +3757,17 @@ export class WarehouseInventoryService { ); if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); - const containers: Array<{ containerNumber: string; goods: string | null }> = + const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> = await this.dataSource.query( `SELECT c.container_number AS "containerNumber", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + (SELECT SUM(u.vgm_tons) + FROM freight.booking_container_units u + JOIN freight.booking_container bc + ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + WHERE u.container_number = c.container_number + AND bc.booking_id = c.booking_id + AND u.deleted_at IS NULL) AS "vgmTons" FROM freight.customer_truck_containers c JOIN freight.bookings b ON b.id = c.booking_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -3654,6 +3776,9 @@ export class WarehouseInventoryService { [assignmentId], ); + // Truck load in tonnes: summed container VGM; the weighed gross only as + // fallback (bulk trucks carry no containers). + const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0); const html = this.buildTruckExitPaperHtml({ reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, bookingReference: truck.bookingReference, @@ -3661,7 +3786,7 @@ export class WarehouseInventoryService { plateNumber: truck.plateNumber, driverName: truck.driverName, truckType: truck.truckType, - grossWeightKg: Number(truck.grossWeightKg ?? 0), + grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)), gateOut: truck.departedAt, containers, }); @@ -5168,7 +5293,7 @@ export class WarehouseInventoryService { truckDriverName?: string | null; truckType?: string | null; truckGateOut?: string | null; - truckWeightKg?: number | null; + truckWeightTons?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5195,8 +5320,8 @@ export class WarehouseInventoryService { ['Quantity', data.quantity], [ data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', - `${(data.truckPlateNumber && data.truckWeightKg - ? data.truckWeightKg + `${(data.truckPlateNumber && data.truckWeightTons + ? data.truckWeightTons : data.weight ).toLocaleString()} t`, ], diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e6ea89fbe..7ebcfb89c 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 396ac95db..1f82f6e93 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, ]), + // Operations Chief: full operational authority — the entire freight + // permission catalog (all CRUD across bookings, contracts, scheduling, + // fleet, warehouse, mile, finance, settings, staff). + operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), + // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, + // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, + // plus truck dispatch on the mile legs and read-only operational context. + // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + dispatcher: dedupe([ + FREIGHT_PERMS.warehouseDashboard.view, + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseYards.view, + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInventory.receive, + FREIGHT_PERMS.warehouseInventory.move, + FREIGHT_PERMS.warehouseInventory.load, + FREIGHT_PERMS.warehouseInventory.unload, + FREIGHT_PERMS.warehouseInventory.dispatch, + FREIGHT_PERMS.warehouseInventory.gatePass, + FREIGHT_PERMS.warehouseInventory.release, + FREIGHT_PERMS.warehouseInventory.deliver, + FREIGHT_PERMS.warehouseInventory.inspect, + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.warehouseFeeInvoices.view, + FREIGHT_PERMS.warehouseFeeInvoices.generate, + // View-only on the rules that govern allocation and fees. + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + // Truck dispatch on the EDR mile legs + operational context. + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.firstMile.assignVehicles, + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.assignVehicles, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.bookings.operations, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ 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: [] }]);