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:
Marshal
2026-08-31 13:56:39 +00:00
parent 678c5d7d49
commit b5ad46f317
23 changed files with 3524 additions and 385 deletions

View File

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

View File

@@ -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',

View File

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

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
{} as never, // transitAssignmentsService
{} as never, // dataSource
);
build([

View File

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

View File

@@ -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),

View File

@@ -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) &&

View File

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

View File

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