mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
fix(operations): prefill booking context on single assign; enforce per-truck container loads
- single-row Assign vehicle uses the full single-record flow (details + containers) - release() rejects exit containers not assigned to the departing truck - weighing modal offers only the selected truck's assigned containers
This commit is contained in:
@@ -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
|
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||||
// total VGM cargo weight of the containers selected as loaded on it.
|
// total VGM cargo weight of the containers selected as loaded on it.
|
||||||
// Skipped when the operator chose not to weigh (containers only).
|
// 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<string[]> {
|
||||||
|
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
|
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||||
* container number. Drives the truck-leaving exit weighing: the selected
|
* container number. Drives the truck-leaving exit weighing: the selected
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Support",
|
label: "Support",
|
||||||
href: "/dashboard/support",
|
href: "/dashboard/support",
|
||||||
icon: <LifeBuoy />,
|
icon: <LifeBuoy />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
@@ -383,26 +384,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Import Overview",
|
label: "Import Overview",
|
||||||
href: "/dashboard/import-warehouse",
|
href: "/dashboard/import-warehouse",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Arrival Queue",
|
label: "Arrival Queue",
|
||||||
href: "/dashboard/arrival-queue",
|
href: "/dashboard/arrival-queue",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dispatch Queue",
|
label: "Dispatch Queue",
|
||||||
href: "/dashboard/dispatch-queue",
|
href: "/dashboard/dispatch-queue",
|
||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Inventory Inquiry",
|
label: "Inventory Inquiry",
|
||||||
href: "/dashboard/inventory-inquiry",
|
href: "/dashboard/inventory-inquiry",
|
||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -416,36 +422,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Export Overview",
|
label: "Export Overview",
|
||||||
href: "/dashboard/export-warehouse",
|
href: "/dashboard/export-warehouse",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Loading Queue",
|
label: "Loading Queue",
|
||||||
href: "/dashboard/loading-queue",
|
href: "/dashboard/loading-queue",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Loaded Inventory",
|
label: "Loaded Inventory",
|
||||||
href: "/dashboard/loaded-inventory",
|
href: "/dashboard/loaded-inventory",
|
||||||
icon: <PackageCheck />,
|
icon: <PackageCheck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dispatch Queue",
|
label: "Dispatch Queue",
|
||||||
href: "/dashboard/dispatch-queue",
|
href: "/dashboard/dispatch-queue",
|
||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Djibouti Unloading",
|
label: "Djibouti Unloading",
|
||||||
href: "/dashboard/export-djibouti-unloading",
|
href: "/dashboard/export-djibouti-unloading",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Interchange Documents",
|
label: "Interchange Documents",
|
||||||
href: "/dashboard/interchange-documents",
|
href: "/dashboard/interchange-documents",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
|
permission: FREIGHT_PERMS.interchangeDocuments.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -459,6 +472,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Intercity Cargo",
|
label: "Intercity Cargo",
|
||||||
href: "/dashboard/intercity",
|
href: "/dashboard/intercity",
|
||||||
icon: <TrainFront />,
|
icon: <TrainFront />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -490,7 +504,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Allocation & Fees",
|
label: "Allocation & Fees",
|
||||||
href: "/dashboard/warehouse-rules",
|
href: "/dashboard/warehouse-rules",
|
||||||
icon: <SlidersHorizontal />,
|
icon: <SlidersHorizontal />,
|
||||||
permission: FREIGHT_PERMS.warehouseAllocationRules.view,
|
permission: [
|
||||||
|
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||||
|
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Fee Invoices",
|
label: "Fee Invoices",
|
||||||
@@ -553,7 +570,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Staff",
|
label: "Staff",
|
||||||
href: "/user-management",
|
href: "/user-management",
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
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));
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemAllowed = (item: SidebarItem): boolean => {
|
// Recursive: children are filtered first; a group (item with children) stays
|
||||||
// GL positions are locked to their single clearance page.
|
// only while it still has visible children — so parents without their own
|
||||||
if (etGl) return isEtClearanceItem(item);
|
// permission key never leak a whole subtree the user cannot open.
|
||||||
if (djGl) return isDjClearanceItem(item);
|
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||||
|
items
|
||||||
// Everyone else: hide the GL-only clearance pages entirely.
|
.map((item) =>
|
||||||
if (isClearanceItem(item)) return false;
|
item.children ? { ...item, children: filterItems(item.children) } : item,
|
||||||
|
)
|
||||||
return permissionAllowed(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
|
// Recursive: a group's own permission gates the whole subtree, leaves are
|
||||||
// checked individually, and a group with no surviving children disappears.
|
// checked individually, and a group with no surviving children disappears.
|
||||||
|
|||||||
@@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: "Booking requests",
|
title: "Booking requests",
|
||||||
description: "Review and action incoming freight bookings",
|
description: "Review and action incoming freight bookings",
|
||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Train scheduling v2",
|
title: "Train scheduling v2",
|
||||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
|
permission: [FREIGHT_PERMS.trainScheduling.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Trains",
|
title: "Trains",
|
||||||
description: "Manage train master data and fleet status",
|
description: "Manage train master data and fleet status",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/trains",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
|
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "User management",
|
title: "User management",
|
||||||
description: "Employees, roles, and permissions",
|
description: "Employees, roles, and permissions",
|
||||||
href: "/user-management",
|
href: "/user-management",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
FREIGHT_PERMS.staff.roles.view,
|
||||||
|
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||||
|
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function OverviewQuickLinks() {
|
export function OverviewQuickLinks() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const visible = links.filter((link) =>
|
||||||
|
link.permission.some((key) => hasPermission(user, key)),
|
||||||
|
);
|
||||||
|
if (!visible.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
<Text fw={600}>Quick links</Text>
|
<Text fw={600}>Quick links</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
{links.map((link) => {
|
{visible.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -378,6 +378,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const containerWeightByNumber = new Map(
|
const containerWeightByNumber = new Map(
|
||||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
containerWeights.map((c) => [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
|
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||||
// the same container number on two lines, so dedupe defensively.
|
// the same container number on two lines, so dedupe defensively.
|
||||||
const containerSelectData = [
|
const containerSelectData = [
|
||||||
@@ -390,7 +393,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).values(),
|
).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 selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||||
const selectedCargoWeight = Number(
|
const selectedCargoWeight = Number(
|
||||||
selectedContainerNumbers
|
selectedContainerNumbers
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||||
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
||||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||||
import "@/components/overview/overview.css";
|
import "@/components/overview/overview.css";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useOverview } from "@/hooks/useOverview";
|
import { useOverview } from "@/hooks/useOverview";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||||
|
|
||||||
const TAB_ITEMS: Array<{
|
const TAB_ITEMS: Array<{
|
||||||
@@ -40,6 +42,8 @@ const TAB_ITEMS: Array<{
|
|||||||
| "customers"
|
| "customers"
|
||||||
| "staff";
|
| "staff";
|
||||||
metricKey: string;
|
metricKey: string;
|
||||||
|
/** Any of these keys grants the tab. */
|
||||||
|
permission: string[];
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
value: "bookings",
|
value: "bookings",
|
||||||
@@ -47,6 +51,7 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: FileText,
|
icon: FileText,
|
||||||
kpiKey: "bookings",
|
kpiKey: "bookings",
|
||||||
metricKey: "totalActive",
|
metricKey: "totalActive",
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "contracts",
|
value: "contracts",
|
||||||
@@ -54,6 +59,7 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: FileSignature,
|
icon: FileSignature,
|
||||||
kpiKey: "contracts",
|
kpiKey: "contracts",
|
||||||
metricKey: "totalActive",
|
metricKey: "totalActive",
|
||||||
|
permission: [FREIGHT_PERMS.contracts.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "billing",
|
value: "billing",
|
||||||
@@ -61,6 +67,7 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: Banknote,
|
icon: Banknote,
|
||||||
kpiKey: "billing",
|
kpiKey: "billing",
|
||||||
metricKey: "successfulPaymentsMtd",
|
metricKey: "successfulPaymentsMtd",
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "operations",
|
value: "operations",
|
||||||
@@ -68,6 +75,12 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: Train,
|
icon: Train,
|
||||||
kpiKey: "operations",
|
kpiKey: "operations",
|
||||||
metricKey: "trainsActive",
|
metricKey: "trainsActive",
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
|
FREIGHT_PERMS.firstMile.view,
|
||||||
|
FREIGHT_PERMS.lastMile.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "customers",
|
value: "customers",
|
||||||
@@ -75,6 +88,7 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
kpiKey: "customers",
|
kpiKey: "customers",
|
||||||
metricKey: "totalCustomers",
|
metricKey: "totalCustomers",
|
||||||
|
permission: [FREIGHT_PERMS.customers.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "staff",
|
value: "staff",
|
||||||
@@ -82,6 +96,12 @@ const TAB_ITEMS: Array<{
|
|||||||
icon: UserCheck,
|
icon: UserCheck,
|
||||||
kpiKey: "staff",
|
kpiKey: "staff",
|
||||||
metricKey: "activeEmployees",
|
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<OverviewRange>("30d");
|
const [range, setRange] = useState<OverviewRange>("30d");
|
||||||
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
||||||
const queryClient = useQueryClient();
|
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 = () => {
|
const handleRefresh = () => {
|
||||||
void refetch();
|
void refetch();
|
||||||
@@ -126,7 +158,7 @@ const OverviewPage = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isError && (
|
{isError && !accessDenied && (
|
||||||
<Alert
|
<Alert
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
color="red"
|
color="red"
|
||||||
@@ -142,48 +174,52 @@ const OverviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs
|
{visibleTabs.length > 0 && (
|
||||||
value={activeTab}
|
<Tabs
|
||||||
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
|
value={currentTab}
|
||||||
variant="pills"
|
onChange={(value) =>
|
||||||
color="edr-green"
|
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
|
||||||
keepMounted={false}
|
}
|
||||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
variant="pills"
|
||||||
>
|
color="edr-green"
|
||||||
<Tabs.List>
|
keepMounted={false}
|
||||||
{TAB_ITEMS.map((tab) => {
|
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||||
const Icon = tab.icon;
|
>
|
||||||
const isActive = activeTab === tab.value;
|
<Tabs.List>
|
||||||
return (
|
{visibleTabs.map((tab) => {
|
||||||
<Tabs.Tab
|
const Icon = tab.icon;
|
||||||
key={tab.value}
|
const isActive = currentTab === tab.value;
|
||||||
value={tab.value}
|
return (
|
||||||
leftSection={<Icon size={17} />}
|
<Tabs.Tab
|
||||||
rightSection={
|
key={tab.value}
|
||||||
summary ? (
|
value={tab.value}
|
||||||
<Badge
|
leftSection={<Icon size={17} />}
|
||||||
size="sm"
|
rightSection={
|
||||||
radius="sm"
|
summary ? (
|
||||||
variant={isActive ? "white" : "light"}
|
<Badge
|
||||||
color={isActive ? "edr-green" : "gray"}
|
size="sm"
|
||||||
>
|
radius="sm"
|
||||||
{getTabBadge(tab)}
|
variant={isActive ? "white" : "light"}
|
||||||
</Badge>
|
color={isActive ? "edr-green" : "gray"}
|
||||||
) : undefined
|
>
|
||||||
}
|
{getTabBadge(tab)}
|
||||||
>
|
</Badge>
|
||||||
{tab.label}
|
) : undefined
|
||||||
</Tabs.Tab>
|
}
|
||||||
);
|
>
|
||||||
})}
|
{tab.label}
|
||||||
</Tabs.List>
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
{TAB_ITEMS.map((tab) => (
|
{visibleTabs.map((tab) => (
|
||||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||||
<OverviewTabContent tab={tab.value} range={range} />
|
<OverviewTabContent tab={tab.value} range={range} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper p="lg" radius="lg" withBorder>
|
<Paper p="lg" radius="lg" withBorder>
|
||||||
<OverviewQuickLinks />
|
<OverviewQuickLinks />
|
||||||
|
|||||||
@@ -936,6 +936,12 @@ const FirstMilePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
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);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||||
|
|||||||
@@ -1066,6 +1066,12 @@ const LastMilePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
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);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
|
|||||||
Reference in New Issue
Block a user