mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
feat: add Transit Clearance Action Panel for managing delivery and release orders
- Implemented TransitClearanceActionPanel component for transit agents to handle DO/RO uploads and amendments. - Added file picker for uploading documents with validation for vessel arrival and collection dates. - Introduced modals for uploading T1 documents and requesting RO amendments. - Enhanced transit assignments service with new API endpoints for clearance history, GL exchange documents, and incident reports. - Updated types to include new document upload sources and statuses. - Created CSS styles for transit bookings table to improve layout and responsiveness. - Exported new TransitAgentBookingDetailPage for detailed booking views.
This commit is contained in:
@@ -116,6 +116,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
|
||||
@@ -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 { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
@@ -176,6 +177,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly yardScope: YardScopeService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
@@ -593,6 +595,17 @@ export class BookingClearanceService {
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// The booking only stores the officer's NAME, which is what the clearance
|
||||
// UI reads. The agent's own portal works off `transit_assignments` rows, so
|
||||
// without this the shipment never reaches the officer's work list — the
|
||||
// desk believes it handed the job over and nothing arrives.
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
@@ -185,6 +186,7 @@ export class ContractClearanceService {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -1230,6 +1232,18 @@ export class ContractClearanceService {
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
// Mirror the name onto the officer's own work list, exactly as the
|
||||
// per-booking path does. Contract-level clearance can be assigned before a
|
||||
// booking exists; in that case there is nothing for the officer to work on
|
||||
// yet, and the booking picks the assignment up when it is created.
|
||||
if (cycle.bookingId) {
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
cycle.bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
|
||||
return updated;
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
{} as never, // transitAssignmentsService
|
||||
{} as never, // dataSource
|
||||
);
|
||||
build([
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -1359,18 +1360,30 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
// Also filed by the transit agent assigned to the shipment — T1 is their own
|
||||
// transit paperwork. Any other portal caller is rejected below.
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
async uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@@ -1534,6 +1547,8 @@ export class ContractsController {
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
// Reads are open to both audiences (a transit agent assigned to the
|
||||
// shipment included); reporting an incident stays staff-only below.
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
|
||||
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
|
||||
import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
@@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||
ContractTemplatesModule,
|
||||
TransitAgentsModule,
|
||||
// Assigning a transit assignee must also land a row in the officer's own
|
||||
// work list. This module is a leaf (it registers Booking as an entity
|
||||
// rather than importing BookingsModule), so no cycle is closed here.
|
||||
TransitAssignmentsModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
forwardRef(() => BookingsModule),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
|
||||
import {
|
||||
GlExchangeService,
|
||||
@@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean =>
|
||||
@ApiBearerAuth()
|
||||
@Controller('gl-exchange')
|
||||
export class GlExchangeController {
|
||||
constructor(private readonly exchangeService: GlExchangeService) {}
|
||||
constructor(
|
||||
private readonly exchangeService: GlExchangeService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// Read opened to the transit agent assigned to the shipment; the POST/PATCH/
|
||||
// DELETE below stay staff-only, so an agent can read the desks' thread but
|
||||
// never post to it.
|
||||
@Get(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
|
||||
})
|
||||
list(
|
||||
async list(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
|
||||
hasFreightPermission(user, p),
|
||||
);
|
||||
if (
|
||||
!isStaff &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
entityId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Entity ${entityId} not found`);
|
||||
}
|
||||
return this.exchangeService.list(entityId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// Open to the transit agent assigned to the shipment as well as both desks:
|
||||
// the officer on the ground is often the one holding the scan either desk
|
||||
// needs. Their post is attributed to the TRANSIT side, never to a desk.
|
||||
@Post(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Share a document with the other GL desk' })
|
||||
upload(
|
||||
@ApiOperation({
|
||||
summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)',
|
||||
})
|
||||
async upload(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const actor = await this.resolveActor(entityId, user);
|
||||
return this.exchangeService.upload(
|
||||
entityId,
|
||||
file,
|
||||
{ title, visibleToCustomer: asBool(visibleToCustomer) },
|
||||
this.actor(user),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +144,36 @@ export class GlExchangeController {
|
||||
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
|
||||
* posts as Ethiopia.
|
||||
*/
|
||||
/**
|
||||
* Who is posting, for a route both desks and the assigned transit agent may
|
||||
* call. Staff keep the desk attribution below; a portal caller must be the
|
||||
* agent assigned to this shipment and posts as TRANSIT, so a document is
|
||||
* never credited to a desk that did not send it.
|
||||
*/
|
||||
private async resolveActor(
|
||||
entityId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<GlExchangeActor> {
|
||||
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
|
||||
hasFreightPermission(user, p),
|
||||
);
|
||||
if (isStaff) return this.actor(user);
|
||||
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
entityId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Entity ${entityId} not found`);
|
||||
}
|
||||
return {
|
||||
userId: resolveAuthUserId(user),
|
||||
name: actorLabel(user) ?? null,
|
||||
side: 'TRANSIT',
|
||||
};
|
||||
}
|
||||
|
||||
private actor(user: TCurrentUser): GlExchangeActor {
|
||||
const side: GlExchangeSide =
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity';
|
||||
*/
|
||||
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
||||
|
||||
export type GlExchangeSide = 'ET' | 'DJ';
|
||||
export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT';
|
||||
|
||||
export interface GlExchangeActor {
|
||||
userId: string;
|
||||
@@ -180,7 +180,14 @@ export class GlExchangeService {
|
||||
// Pre-title rows (none in practice) fall back to the filename so a list
|
||||
// never renders a blank row.
|
||||
title: record.title ?? record.name,
|
||||
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
||||
// `files.code` carries the poster's side. Anything unrecognised reads as
|
||||
// ET, which is how every pre-TRANSIT row was written.
|
||||
side:
|
||||
record.code === 'DJ'
|
||||
? 'DJ'
|
||||
: record.code === 'TRANSIT'
|
||||
? 'TRANSIT'
|
||||
: 'ET',
|
||||
visibleToCustomer: record.visibleToCustomer,
|
||||
uploadedById: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{} as never, // dataSource
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user