mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
* humanized handler name where a route has none.
|
||||
*
|
||||
* Excludes the AI Assist and Account entities.
|
||||
* Generated from the controllers under src/ — 512 endpoints.
|
||||
* Generated from the controllers under src/ — 517 endpoints.
|
||||
*/
|
||||
/** [title, method, entity] for one auditable route. */
|
||||
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
||||
@@ -102,6 +102,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"],
|
||||
"POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"],
|
||||
|
||||
// Chat
|
||||
"POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"],
|
||||
|
||||
// Company
|
||||
"POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"],
|
||||
"POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"],
|
||||
@@ -129,6 +132,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
|
||||
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
|
||||
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
|
||||
"POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"],
|
||||
|
||||
// Compliance
|
||||
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
|
||||
@@ -256,6 +260,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
|
||||
|
||||
// Exchange Setting
|
||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||
|
||||
@@ -940,8 +940,8 @@ export class BookingsController {
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
getBookingEtClearanceQueue(@CurrentUser() user: unknown) {
|
||||
return this.bookingClearanceService.etQueue(user);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
|
||||
@@ -32,11 +32,14 @@ function makeService(overrides?: {
|
||||
workflowThrows?: boolean;
|
||||
/** Resolve the input doc set with no required fields → every doc counts approved. */
|
||||
docsApproved?: boolean;
|
||||
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
|
||||
yardScope?: string[] | null;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(booking),
|
||||
findByStatuses: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
@@ -111,6 +114,7 @@ function makeService(overrides?: {
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -124,6 +128,30 @@ function makeService(overrides?: {
|
||||
}
|
||||
|
||||
describe('BookingClearanceService', () => {
|
||||
describe('etQueue yard scope', () => {
|
||||
const queueBookings = [
|
||||
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
|
||||
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
|
||||
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
|
||||
] as unknown as Booking[];
|
||||
|
||||
it('keeps only bookings whose origin or destination is in scope', async () => {
|
||||
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
|
||||
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
|
||||
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
|
||||
const rows = await service.etQueue({});
|
||||
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
|
||||
});
|
||||
|
||||
it('shows everything when the position has no yard mapping', async () => {
|
||||
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
|
||||
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
|
||||
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
|
||||
const rows = await service.etQueue({});
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adviseDuty', () => {
|
||||
it('skips duty milestones when duty is not required', async () => {
|
||||
const { service, workflowService, bookingsRepository } = makeService();
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
@@ -158,6 +159,7 @@ export class BookingClearanceService {
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -981,7 +983,7 @@ export class BookingClearanceService {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async etQueue(): Promise<Booking[]> {
|
||||
async etQueue(user?: unknown): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
||||
]);
|
||||
@@ -991,7 +993,26 @@ export class BookingClearanceService {
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
}
|
||||
return this.attachContractSummary(filtered);
|
||||
const rows = await this.attachContractSummary(filtered);
|
||||
return this.narrowToYardScope(rows, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
|
||||
* assigned yards (`freight.yard_positions` via the active position). Yards in
|
||||
* the middle of a route do not count. An unmapped position, super admin or
|
||||
* `yards:view_all` holder sees everything (scope resolves to `null`).
|
||||
* Runs after {@link attachContractSummary} so route-fallback yards count too.
|
||||
*/
|
||||
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
|
||||
const scope = await this.yardScope.getScopedYardIds(user as never);
|
||||
if (scope === null) return bookings;
|
||||
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
|
||||
return bookings.filter(
|
||||
(b) =>
|
||||
inScope(b.originYardId ?? b.originYard?.id) ||
|
||||
inScope(b.destinationYardId ?? b.destinationYard?.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
async findQueue(): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: { contract: { company: true } },
|
||||
// `routes` rides along so the queue can be narrowed to the caller's yards.
|
||||
relations: { contract: { company: true, routes: true } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
@@ -28,6 +29,7 @@ export class BookingRequestService {
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -168,8 +170,25 @@ export class BookingRequestService {
|
||||
return request;
|
||||
}
|
||||
|
||||
queue(): Promise<BookingRequest[]> {
|
||||
return this.repo.findQueue();
|
||||
/**
|
||||
* GL queue narrowed to the caller's yards: a request stays when its route's
|
||||
* ORIGIN or DESTINATION yard is one the caller's active position is mapped to
|
||||
* (unmapped position / super admin → everything). A request with no
|
||||
* resolvable route (no `contractRouteId` on a multi-route contract) has no
|
||||
* yards to judge by and is kept visible.
|
||||
*/
|
||||
async queue(user?: unknown): Promise<BookingRequest[]> {
|
||||
const rows = await this.repo.findQueue();
|
||||
const scope = await this.yardScope.getScopedYardIds(user as never);
|
||||
if (scope === null) return rows;
|
||||
return rows.filter((r) => {
|
||||
const routes = r.contract?.routes ?? [];
|
||||
const route =
|
||||
routes.find((x) => x.id === r.contractRouteId) ??
|
||||
(routes.length === 1 ? routes[0] : undefined);
|
||||
if (!route) return true;
|
||||
return scope.includes(route.originYardId) || scope.includes(route.destinationYardId);
|
||||
});
|
||||
}
|
||||
|
||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||
|
||||
@@ -123,8 +123,8 @@ export class ContractsController {
|
||||
@Get('booking-requests/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
|
||||
bookingRequestQueue() {
|
||||
return this.bookingRequestService.queue();
|
||||
bookingRequestQueue(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.bookingRequestService.queue(user);
|
||||
}
|
||||
|
||||
@Get('booking-requests/:reqId')
|
||||
|
||||
@@ -42,6 +42,7 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
FREIGHT_PERMS.trains.changeLocomotives,
|
||||
FREIGHT_PERMS.trains.changeYard,
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
])
|
||||
@@ -107,6 +108,26 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.setYard(id, dto.currentYardId);
|
||||
}
|
||||
|
||||
@Patch(':id/wagons/:wagonId/yard')
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated',
|
||||
})
|
||||
setWagonYard(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: UpdateTrainYardDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.setWagonYard(
|
||||
id,
|
||||
wagonId,
|
||||
dto.currentYardId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
|
||||
@@ -514,6 +514,42 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move ONE coupled wagon to another yard (the train and the rest of the
|
||||
* consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED)
|
||||
* schedule has the wagon allocated to a slot — its standing yard is part of
|
||||
* that schedule's route validation. Ledger row mirrors `setYard`.
|
||||
*/
|
||||
async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||
}
|
||||
if (wagon.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -541,11 +577,7 @@ export class TrainBuilderService {
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
@@ -582,11 +614,7 @@ export class TrainBuilderService {
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
||||
const previousStatus = wagon.status;
|
||||
const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
@@ -669,6 +697,57 @@ export class TrainBuilderService {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly
|
||||
* pinned only while a live schedule still NEEDS it: a slot carrying booking
|
||||
* allocations, or any slot on a DISPATCHED run. An empty (allocation-free)
|
||||
* slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was
|
||||
* moved to another wagon (moveWagonLoad keeps the emptied slot) or its
|
||||
* booking left through a path that didn't clean up — and used to pin the
|
||||
* wagon forever. Release those slots here instead of blocking, with the
|
||||
* same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters
|
||||
* feed the schedule capacity math).
|
||||
*/
|
||||
private async assertDetachableAndReleaseStaleSlots(
|
||||
manager: EntityManager,
|
||||
wagon: Wagon,
|
||||
): Promise<void> {
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||
(SELECT count(*)
|
||||
FROM freight.wagon_booking_allocations a
|
||||
WHERE a.train_set_wagon_id = tsw.id
|
||||
AND a.deleted_at IS NULL) AS allocs
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL`,
|
||||
[wagon.id],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id));
|
||||
for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) {
|
||||
const remaining = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { trainSetId },
|
||||
select: { id: true, lengthMeters: true },
|
||||
});
|
||||
await manager.getRepository(TrainSet).update(trainSetId, {
|
||||
wagonCount: remaining.length,
|
||||
totalLengthMeters: round(
|
||||
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
@@ -900,6 +900,11 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:trains:disband",
|
||||
"Disband train",
|
||||
),
|
||||
perm(
|
||||
"e1c00001-0001-4000-8000-000000000010",
|
||||
"edr_freight_app:trains:change_wagon_yard",
|
||||
"Change yard of a coupled wagon",
|
||||
),
|
||||
perm(
|
||||
"e1d00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:routes:view",
|
||||
@@ -2007,6 +2012,7 @@ export const FREIGHT_PERMS = {
|
||||
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||
changeYard: "edr_freight_app:trains:change_yard",
|
||||
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
|
||||
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||
disband: "edr_freight_app:trains:disband",
|
||||
},
|
||||
@@ -2343,6 +2349,7 @@ const FLEET_GRANULAR_KEYS: string[] = [
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.trains.changeLocomotives,
|
||||
FREIGHT_PERMS.trains.changeYard,
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.routes.view,
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
|
||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
import { wagonTypeColor } from "./trainStatus";
|
||||
|
||||
@@ -38,6 +40,7 @@ function ConsistWagonList({
|
||||
onReorder,
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = useCallback((result: DropResult) => {
|
||||
@@ -107,6 +110,7 @@ function ConsistWagonList({
|
||||
busy={busy}
|
||||
onRemove={onRemove}
|
||||
onMaintenance={onMaintenance}
|
||||
onChangeYard={onChangeYard}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
@@ -129,9 +133,68 @@ export interface ConsistWagonListProps {
|
||||
onRemove: (wagonId: string) => void;
|
||||
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/** Yard badge that opens a yard picker when `onChange` is provided. */
|
||||
function WagonYardBadge({
|
||||
wagon,
|
||||
busy,
|
||||
onChange,
|
||||
}: {
|
||||
wagon: TrainCompositionWagon;
|
||||
busy: boolean;
|
||||
onChange?: (wagonId: string, currentYardId: string) => void;
|
||||
}) {
|
||||
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
|
||||
);
|
||||
if (!onChange) {
|
||||
return wagon.currentYard ? (
|
||||
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<Menu shadow="md" width={240} withinPortal>
|
||||
<Menu.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
variant="outline"
|
||||
color="blue"
|
||||
size="xs"
|
||||
radius="sm"
|
||||
leftSection={<MapPin size={10} />}
|
||||
disabled={busy}
|
||||
style={{ cursor: busy ? "default" : "pointer" }}
|
||||
// Stop the drag handle from swallowing the click.
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label={`Change yard of wagon ${wagon.wagonNumber}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Move wagon to yard</Menu.Label>
|
||||
{(yardsQuery.data ?? []).map((y) => (
|
||||
<Menu.Item
|
||||
key={y.id}
|
||||
disabled={y.id === wagon.currentYard?.id}
|
||||
onClick={() => onChange(wagon.id, y.id)}
|
||||
>
|
||||
{y.label ?? y.code}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const WagonRow = memo(function WagonRow({
|
||||
wagon,
|
||||
index,
|
||||
@@ -141,6 +204,7 @@ const WagonRow = memo(function WagonRow({
|
||||
busy,
|
||||
onRemove,
|
||||
onMaintenance,
|
||||
onChangeYard,
|
||||
}: {
|
||||
wagon: TrainCompositionWagon;
|
||||
index: number;
|
||||
@@ -150,6 +214,7 @@ const WagonRow = memo(function WagonRow({
|
||||
busy: boolean;
|
||||
onRemove: (wagonId: string) => void;
|
||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||
}) {
|
||||
const color = wagonTypeColor(wagon.wagonType?.code);
|
||||
|
||||
@@ -195,11 +260,7 @@ const WagonRow = memo(function WagonRow({
|
||||
{wagon.wagonType.code}
|
||||
</Badge>
|
||||
) : null}
|
||||
{wagon.currentYard ? (
|
||||
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
|
||||
{wagon.currentYard.label ?? wagon.currentYard.code}
|
||||
</Badge>
|
||||
) : null}
|
||||
<WagonYardBadge wagon={wagon} busy={busy} onChange={onChangeYard} />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
|
||||
@@ -218,6 +218,8 @@ export const FREIGHT_PERMS = {
|
||||
/** Train-builder detail Actions menu — each item its own grant. */
|
||||
changeLocomotives: "edr_freight_app:trains:change_locomotives",
|
||||
changeYard: "edr_freight_app:trains:change_yard",
|
||||
/** Move ONE coupled wagon to another yard from the Wagon order list. */
|
||||
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
|
||||
toggleActive: "edr_freight_app:trains:toggle_active",
|
||||
disband: "edr_freight_app:trains:disband",
|
||||
},
|
||||
|
||||
@@ -94,6 +94,7 @@ export default function TrainBuilderDetailPage() {
|
||||
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 canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||
const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive);
|
||||
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||
|
||||
@@ -109,6 +110,7 @@ export default function TrainBuilderDetailPage() {
|
||||
);
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
||||
const maintenanceWagon = useMutation(
|
||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||
);
|
||||
@@ -162,6 +164,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const busy =
|
||||
assignWagons.isPending ||
|
||||
removeWagon.isPending ||
|
||||
setWagonYard.isPending ||
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
@@ -217,6 +220,16 @@ export default function TrainBuilderDetailPage() {
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId],
|
||||
);
|
||||
const handleChangeWagonYard = useCallback(
|
||||
(wagonId: string, currentYardId: string) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => setWagonYard.mutateAsync({ id: trainId, wagonId, currentYardId }),
|
||||
"Could not change wagon yard",
|
||||
);
|
||||
},
|
||||
[withToast, setWagonYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
@@ -499,6 +512,9 @@ export default function TrainBuilderDetailPage() {
|
||||
onReorder={handleReorder}
|
||||
onRemove={handleRemove}
|
||||
onMaintenance={handleMaintenance}
|
||||
onChangeYard={
|
||||
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -2132,6 +2132,19 @@ export const api = {
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
setWagonYard: endpoint<
|
||||
{ id: string; wagonId: string; currentYardId: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"setWagonYard",
|
||||
({ id, wagonId, currentYardId }) =>
|
||||
trainBuilderService.setWagonYard(id, wagonId, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"removeWagon",
|
||||
|
||||
@@ -315,6 +315,9 @@ export const trainBuilderService = {
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
/** Move one coupled wagon to another yard; the train stays put. */
|
||||
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user