feat(permissions): add granular permissions for train management actions

feat(train-builder): update permissions checks for train actions in UI
feat(contracts): enhance contract view and shipment request pages with new features
test(shipment-preview): add tests for customs clearing logic in booking preview
style(contract-sign-bar): create CSS for fixed sign bar layout
This commit is contained in:
marshalyordanos
2026-08-11 11:48:16 +03:00
parent ea6eccbf09
commit 07a120af5e
9 changed files with 267 additions and 73 deletions

View File

@@ -1974,6 +1974,11 @@ export class ContractBookingService {
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
// The clearance fee is gated on this flag in BookingPricingService, and
// createUnderContract copies it off the contract. Omitting it here priced
// the preview WITHOUT the customs line the created booking is then billed
// — the customer confirmed one total and got invoiced a larger one.
customsClearingEnabled: contract.customsClearingEnabled,
shippingLineId: null,
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,

View File

@@ -0,0 +1,123 @@
import { ContractBookingService } from './contract-booking.service';
import type { Contract } from './entities/contract.entity';
import type { Booking } from '../bookings/entities/booking.entity';
import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/**
* The price the customer confirms in the modal comes from validateShipment,
* which prices an UNSAVED twin of the booking createUnderContract will write.
* Any contract field that reaches the pricing service must be copied onto that
* twin — a field left off doesn't fail loudly, it silently drops whole charge
* lines from the quote while the created booking is still billed for them.
*
* The regression this locks: `customsClearingEnabled` was missing, so
* BookingPricingService's `if (booking.customsClearingEnabled)` gate never
* opened in the preview. Container bookings quoted rail freight alone, then
* invoiced rail + customs clearance.
*/
describe('shipment preview / created booking parity', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c1',
freightType: 'CONTAINER',
tradeDirection: 'EXPORT',
paymentCurrency: 'ETB',
serviceTypeId: 'svc1',
customsClearingEnabled: true,
equipmentReturn: 'NO_RETURN',
isHazardous: false,
isReefer: false,
isGovernment: false,
cargoScope: [],
firstMilePickupAddress: null,
lastMileDeliveryAddress: null,
...over,
}) as Contract;
/**
* Run validateShipment against stubbed collaborators and hand back the
* booking the pricing service was actually asked to price.
*/
const previewBookingFor = async (c: Contract): Promise<Booking> => {
let priced: Booking | null = null;
const svc = {
contractsRepository: { findByIdWithRelations: async () => c },
bookingPricingService: {
computePriceForBooking: async (b: Booking) => {
priced = b;
return {
lineItems: [],
totalAmount: 0,
currency: 'ETB',
overweightLines: [],
hardBlocked: [],
};
},
},
ruleEngineService: { capacityViolations: async () => [] },
resolveRoute: async () => null,
resolveShipmentCurrency: () => 'ETB',
resolveCargoTypeId: () => null,
resolveShipmentHandlingFlag: () => false,
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
resolveBulkTons: () => 0,
resolveBulkWeightTons: () => 0,
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
handlingCounts: () => ({
hazardousQuantity: 0,
reeferQuantity: 0,
returnQuantity: 0,
}),
max20ftPairDiffTons: async () => 2,
findContainerClashesOnTrain: async () => [],
};
const dto = {
containers: [
{
containerSize: '40ft',
quantity: 2,
units: [{ vgmTons: 10 }, { vgmTons: 10 }],
},
],
} as unknown as CreateBookingUnderContractDto;
await (
ContractBookingService.prototype as unknown as {
validateShipment: (
this: unknown,
id: string,
dto: CreateBookingUnderContractDto,
) => Promise<unknown>;
}
).validateShipment.call(svc, 'c1', dto);
if (!priced) throw new Error('pricing service was never called');
return priced;
};
it('prices the preview with customs clearing on when the contract clears', async () => {
// Without this the clearance fee is quoted as 0 and billed in full later.
const booking = await previewBookingFor(contract());
expect(booking.customsClearingEnabled).toBe(true);
});
it('leaves customs clearing off when the contract does not clear', async () => {
const booking = await previewBookingFor(
contract({ customsClearingEnabled: false }),
);
expect(booking.customsClearingEnabled).toBe(false);
});
it('carries the contract mile legs so trucking is quoted too', async () => {
const booking = await previewBookingFor(
contract({
firstMilePickupAddress: 'Modjo Dry Port',
lastMileDeliveryAddress: 'Djibouti Port',
}),
);
expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port');
expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port');
});
});

View File

@@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@@ -72,7 +76,7 @@ export class TrainBuilderController {
}
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@@ -94,7 +98,7 @@ export class TrainBuilderController {
}
@Patch(':id/yard')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.changeYard)
@ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
})
@@ -143,7 +147,7 @@ export class TrainBuilderController {
}
@Post(':id/deactivate')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
@ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
})
@@ -152,14 +156,14 @@ export class TrainBuilderController {
}
@Post(':id/activate')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id);
}
@Delete(':id')
@FleetManage(FREIGHT_PERMS.trains.delete)
@FleetManage(FREIGHT_PERMS.trains.disband)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:trains:assign_wagons",
"Assign wagons to train",
),
// Granular splits of trains:update / trains:delete for the train-builder
// detail page's Actions menu — each item gets its own grant instead of
// sharing the coarse update/delete keys.
perm(
"e1c00001-0001-4000-8000-000000000006",
"edr_freight_app:trains:change_locomotives",
"Change train locomotives",
),
perm(
"e1c00001-0001-4000-8000-000000000007",
"edr_freight_app:trains:change_yard",
"Change train yard",
),
perm(
"e1c00001-0001-4000-8000-000000000008",
"edr_freight_app:trains:toggle_active",
"Activate or deactivate train",
),
perm(
"e1c00001-0001-4000-8000-000000000009",
"edr_freight_app:trains:disband",
"Disband train",
),
perm(
"e1d00001-0001-4000-8000-000000000001",
"edr_freight_app:routes:view",
@@ -1714,6 +1737,10 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
routes: {
view: "edr_freight_app:routes:view",
@@ -2021,6 +2048,10 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.routes.view,
FREIGHT_PERMS.routes.create,
FREIGHT_PERMS.routes.update,

View File

@@ -188,6 +188,11 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
/** Train-builder detail Actions menu — each item its own grant. */
changeLocomotives: "edr_freight_app:trains:change_locomotives",
changeYard: "edr_freight_app:trains:change_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
},
routes: {
view: "edr_freight_app:routes:view",

View File

@@ -47,7 +47,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
@@ -83,9 +83,11 @@ export default function TrainBuilderDetailPage() {
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -172,7 +174,7 @@ export default function TrainBuilderDetailPage() {
</Group>
}
action={
canUpdate || canDelete ? (
canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? (
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
@@ -180,47 +182,49 @@ export default function TrainBuilderDetailPage() {
</Button>
</Menu.Target>
<Menu.Dropdown>
{canUpdate ? (
<>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
</>
{canChangeLocomotives ? (
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
) : null}
{canDelete ? (
{canChangeYard ? (
<Menu.Item
leftSection={<MapPin size={15} />}
disabled={!composition.editable}
onClick={() => setYardModalOpen(true)}
>
Change yard
</Menu.Item>
) : null}
{canToggleActive ? (
composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)
) : null}
{canDisband ? (
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() {
.
</Text>
<Group gap="xs">
{canUpdate ? (
{canChangeLocomotives ? (
<Button
size="compact-sm"
variant="light"

View File

@@ -33,6 +33,8 @@ import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
import "./contract-sign-bar.css";
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
/**
@@ -225,7 +227,7 @@ export default function ContractViewPage() {
return (
<Box
p={{ base: "md", md: "xl" }}
pb={data.canSignCustomer ? 120 : undefined}
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
@@ -294,16 +296,19 @@ export default function ContractViewPage() {
style={{
position: "fixed",
bottom: 0,
left: 0,
left: "var(--sign-bar-left, 0px)",
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-body)",
paddingBottom: 32,
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
maxHeight: "80vh",
overflowY: "auto",
}}
className="contract-sign-bar"
>
<Box maw={920} mx="auto">
<Stack gap="sm">
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.currentTarget.checked)}
@@ -315,17 +320,15 @@ export default function ContractViewPage() {
: "Read the full contract above before you can agree and sign."
}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Stack>
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
disabled={!canProceedToSign}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
</Group>
</Box>
</Paper>
)}

View File

@@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
const isIntercity = contract.tradeDirection === "DOMESTIC";
// Export shipments are invoiced in ETB only — USD is not offered.
const isExport = contract.tradeDirection === "EXPORT";
// Only the container sizes the contract was scoped for (20ft, 40ft, or both).
const SIZE_ORDER = ["20ft", "40ft"];
@@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
paymentCurrency: isIntercity ? "ETB" : paymentCurrency,
paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency,
notes: notes.trim() || undefined,
};
@@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() {
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
: isExport
? "Export shipments are invoiced in ETB."
: "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
value={isIntercity || isExport ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
disabled={isIntercity || isExport}
data={
isExport
? [{ label: "ETB", value: "ETB" }]
: [
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]
}
color="teal"
radius={10}
/>

View File

@@ -0,0 +1,11 @@
/* Keeps the fixed sign bar confined to the content area (right of the
navbar) instead of spanning the full viewport and drifting off-center. */
.contract-sign-bar {
--sign-bar-left: 0px;
}
@media (min-width: 48em) {
.contract-sign-bar {
--sign-bar-left: 260px;
}
}