mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
feat: implement 20ft container weight-pairing validation
- Added ContainerValidationService to handle 20ft weight-pairing logic. - Introduced validate20ftWeightPairing utility function to check weight differences. - Updated BookingPricingService to include overweight line details and pairing errors in price response. - Enhanced BookingTransitionService to reject submissions with unpairable 20ft containers. - Created ShipmentValidation interface for pre-submit validation of container contracts. - Integrated shipment validation into the contract booking process, providing warnings for overweight containers and hard blocks for pairing errors. - Updated front-end components to display validation results and prevent submission when errors are present.
This commit is contained in:
@@ -61,7 +61,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
hasPermission as hasFreightPermission,
|
||||
isDjiboutiGl,
|
||||
isEthiopianGl,
|
||||
isSuperAdmin,
|
||||
} from "./lib/permissions";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
@@ -435,12 +441,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
];
|
||||
|
||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||
/** Hrefs of the two document-clearance menu items (stable identifiers). */
|
||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === ET_CLEARANCE_HREF;
|
||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === DJ_CLEARANCE_HREF;
|
||||
const isClearanceItem = (item: SidebarItem): boolean =>
|
||||
isEtClearanceItem(item) || isDjClearanceItem(item);
|
||||
|
||||
/**
|
||||
* Keep only items the user is permitted to see; drop now-empty sections.
|
||||
*
|
||||
* Position-scoped visibility (super_admin bypasses all of this):
|
||||
* - Ethiopian GL → sees ONLY the ET document-clearance page.
|
||||
* - Djibouti GL → sees ONLY the DJ clearance page.
|
||||
* - Everyone else → sees everything they have permission for, EXCEPT the two
|
||||
* clearance pages (those are GL-only).
|
||||
*/
|
||||
const filterSidebarByPermission = (
|
||||
sections: SidebarSection[],
|
||||
user: ReturnType<typeof useAuth>["user"],
|
||||
): SidebarSection[] => {
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const etGl = !superAdmin && isEthiopianGl(user);
|
||||
const djGl = !superAdmin && isDjiboutiGl(user);
|
||||
|
||||
const permissionAllowed = (item: SidebarItem): boolean => {
|
||||
if (!item.permission) return true;
|
||||
const keys = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
@@ -448,6 +477,19 @@ const filterSidebarByPermission = (
|
||||
return keys.some((key) => hasFreightPermission(user, key));
|
||||
};
|
||||
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
if (superAdmin) return true;
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
@@ -469,6 +511,22 @@ const DashboardShell = () => {
|
||||
);
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
|
||||
// GL positions are locked to their single clearance page: if they navigate
|
||||
// (or deep-link) anywhere else, send them back to their clearance hub.
|
||||
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const glClearanceHome = !superAdmin
|
||||
? isEthiopianGl(user)
|
||||
? ET_CLEARANCE_HREF
|
||||
: isDjiboutiGl(user)
|
||||
? DJ_CLEARANCE_HREF
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FreightDashboardLayout
|
||||
sidebarSections={sidebarSections}
|
||||
|
||||
@@ -73,6 +73,38 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const emp of user.employee ?? []) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
if (pos.key) keys.add(pos.key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasPosition(
|
||||
user: AuthUser | null | undefined,
|
||||
positionKey: string,
|
||||
): boolean {
|
||||
return getPositionKeys(user).includes(positionKey);
|
||||
}
|
||||
|
||||
export const POSITION_KEYS = {
|
||||
ethiopianGl: "ethiopian_gl",
|
||||
djiboutiGl: "djibouti_gl",
|
||||
} as const;
|
||||
|
||||
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
||||
return hasPosition(user, POSITION_KEYS.ethiopianGl);
|
||||
}
|
||||
|
||||
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
||||
return hasPosition(user, POSITION_KEYS.djiboutiGl);
|
||||
}
|
||||
|
||||
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||
if (user?.isSuperAdmin) return true;
|
||||
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
||||
|
||||
@@ -126,6 +126,8 @@ export const URL_CONSTANTS = {
|
||||
`/api/contracts/${id}/clearance/documents`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||
VALIDATE_SHIPMENT: (id: string) =>
|
||||
`/api/contracts/${id}/validate-shipment`,
|
||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||
BOOKING_MILESTONES: (bookingId: string) =>
|
||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
import { OperationDatePicker } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import type { ShipmentValidation } from "@/services/contracts.service";
|
||||
import {
|
||||
SelectField,
|
||||
StepCard,
|
||||
@@ -149,6 +151,8 @@ function NewShipmentBookingForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const isContainerContract = contract.freightType === "CONTAINER";
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||
@@ -161,6 +165,14 @@ function NewShipmentBookingForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-submit validation (container contracts only): warns on overweight
|
||||
// containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
|
||||
// price modal opens so re-reviewing after an edit re-checks.
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
api.contracts.validateShipment.call({ id: contractId, dto }),
|
||||
});
|
||||
|
||||
function buildDto(
|
||||
values: ShipmentFormValues,
|
||||
): Freight.CreateBookingUnderContractDto {
|
||||
@@ -207,13 +219,22 @@ function NewShipmentBookingForm({
|
||||
};
|
||||
}
|
||||
|
||||
// Submit validates the whole form, then opens the price modal for confirmation.
|
||||
// Submit validates the whole form, then opens the price modal for
|
||||
// confirmation. For container contracts we also run the server-side shipment
|
||||
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
|
||||
// can surface them before the booking is created.
|
||||
const handleReview = form.handleSubmit((values) => {
|
||||
setPendingValues(values);
|
||||
if (isContainerContract) {
|
||||
validateMutation.reset();
|
||||
validateMutation.mutate(buildDto(values));
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!pendingValues) return;
|
||||
// Guard: never let a booking with unresolved 20ft pairing errors submit.
|
||||
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
|
||||
submitMutation.mutate(buildDto(pendingValues));
|
||||
};
|
||||
|
||||
@@ -221,6 +242,7 @@ function NewShipmentBookingForm({
|
||||
const handleReject = () => {
|
||||
if (submitMutation.isPending) return;
|
||||
setPendingValues(null);
|
||||
validateMutation.reset();
|
||||
};
|
||||
|
||||
const routes = contract.routes ?? [];
|
||||
@@ -323,6 +345,8 @@ function NewShipmentBookingForm({
|
||||
contract={contract}
|
||||
values={pendingValues}
|
||||
loading={submitMutation.isPending}
|
||||
validation={validateMutation.data ?? null}
|
||||
validationLoading={validateMutation.isPending}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
@@ -334,12 +358,16 @@ function PriceConfirmModal({
|
||||
contract,
|
||||
values,
|
||||
loading,
|
||||
validation,
|
||||
validationLoading,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
values: ShipmentFormValues | null;
|
||||
loading: boolean;
|
||||
validation: ShipmentValidation | null;
|
||||
validationLoading: boolean;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
@@ -348,6 +376,11 @@ function PriceConfirmModal({
|
||||
[contract, values],
|
||||
);
|
||||
|
||||
const overweightLines = validation?.overweightLines ?? [];
|
||||
const pairingErrors = validation?.pairingErrors ?? [];
|
||||
const hasPairingBlock = pairingErrors.length > 0;
|
||||
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(values)}
|
||||
@@ -376,6 +409,60 @@ function PriceConfirmModal({
|
||||
>
|
||||
{total ? (
|
||||
<Stack gap="md">
|
||||
{validationLoading && (
|
||||
<Group gap={8} c="dimmed">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
<Text fz="sm" c="dimmed">
|
||||
Checking container weights and wagon pairing…
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{hasPairingBlock && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot create booking — 20ft wagon pairing"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{pairingErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Adjust the 20ft container weights or quantities so pairs differ
|
||||
by no more than 10 tons.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Overweight containers"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{overweightLines.map((line, i) => (
|
||||
<Text key={i} fz="sm" c="#9A5B00">
|
||||
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
An overweight surcharge applies. You can still submit, or go
|
||||
back and adjust weights.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Stack gap={10}>
|
||||
{total.lines.map((line, i) => (
|
||||
@@ -442,6 +529,7 @@ function PriceConfirmModal({
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ContractDocuments,
|
||||
GenerateContractPriceResponse,
|
||||
SubmitContractResponse,
|
||||
ShipmentValidation,
|
||||
} from "./contracts.service";
|
||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||
import {
|
||||
@@ -462,6 +463,13 @@ export const api = {
|
||||
contractsService.createBookingUnderContract(id, dto),
|
||||
),
|
||||
|
||||
validateShipment: endpoint<
|
||||
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||
ShipmentValidation
|
||||
>("contracts", "validateShipment", ({ id, dto }) =>
|
||||
contractsService.validateShipment(id, dto),
|
||||
),
|
||||
|
||||
getContractMilestones: endpoint<
|
||||
{ id: string },
|
||||
Freight.IClearanceMilestone[]
|
||||
|
||||
@@ -33,6 +33,25 @@ export interface SubmitContractResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** A container line whose total VGM exceeds the weight-limit rule. */
|
||||
export interface OverweightLine {
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-submit validation for a shipment booking under a CONTAINER contract.
|
||||
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||
* that cannot be balanced onto wagons) and must prevent booking.
|
||||
*/
|
||||
export interface ShipmentValidation {
|
||||
overweightLines: OverweightLine[];
|
||||
pairingErrors: string[];
|
||||
}
|
||||
|
||||
export interface ContractListFilter {
|
||||
status?: string;
|
||||
statuses?: string;
|
||||
@@ -285,6 +304,20 @@ export const contractsService = {
|
||||
return data.data.booking ?? data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pre-submit validation of a shipment booking (same DTO as
|
||||
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
|
||||
* booking is created.
|
||||
*/
|
||||
validateShipment: async (
|
||||
id: string,
|
||||
dto: Freight.CreateBookingUnderContractDto,
|
||||
): Promise<ShipmentValidation> => {
|
||||
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
// ── Milestones ──
|
||||
getContractMilestones: async (
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user