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:
Marshal
2026-07-03 09:26:27 +00:00
parent d832e83b4a
commit 35cb20da0b
20 changed files with 646 additions and 5 deletions

View File

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

View File

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