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:
Hagernesh
2026-07-22 07:03:49 +00:00
parent fd3212f326
commit 19906b273e
7 changed files with 213 additions and 57 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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 />

View File

@@ -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: "" }]);

View File

@@ -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: [] }]);