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

@@ -6,6 +6,7 @@ import {
ForbiddenException,
Get,
HttpCode,
NotFoundException,
Param,
ParseUUIDPipe,
Patch,
@@ -783,6 +784,64 @@ export class BookingsController {
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
/**
* Scope a clearance READ that a transit agent may be making.
*
* Transit agents are portal accounts holding no permission and belonging to
* no company, so the audience guards admit them but the usual company-based
* ownership check would 404 every booking. This narrows them to the shipments
* assigned to them and leaves every other caller — staff and owning customers
* — on the path they already had. Purely widening: nothing that passed before
* starts failing here.
*/
private async assertTransitAgentScope(
bookingId: string,
user: TCurrentUser,
): Promise<void> {
const userId = user?.id;
if (!userId) return;
if (!(await this.bookingsService.isTransitAgent(userId))) return;
if (
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
) {
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
// customer-ownership failure mode.
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
/**
* Gate a formerly staff-only clearance route that is now MixedAudience.
*
* Staff still pass on their permission. A portal caller must be a transit
* agent assigned to THIS booking — an ordinary customer is rejected, because
* relaxing the guard must not hand the whole customer base a route that was
* previously staff-only.
*
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
* assigned agent files them in the desk's place, and the assignment is the
* only thing standing between a portal token and the customs record.
*/
private async assertPortalClearanceAccess(
bookingId: string,
user: TCurrentUser,
): Promise<void> {
if (
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
) {
return;
}
if (
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
@@ -1128,6 +1187,9 @@ export class BookingsController {
}
@Get(":id/clearance")
// A transit agent is a portal account, so MixedAudience admits them without a
// permission; `assertTransitAgentScope` below narrows them to the shipments
// actually assigned to them.
@MixedAudience([
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
@@ -1136,7 +1198,11 @@ export class BookingsController {
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param("id", ParseUUIDPipe) id: string) {
async getClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertTransitAgentScope(id, user);
return this.transitionService.getClearanceView(id);
}
@@ -1278,8 +1344,11 @@ export class BookingsController {
return { success: true };
}
// Was staff-only. Opened to the transit agent assigned to the shipment, who
// needs the clearance trail for the bookings they handle; every other portal
// account is still rejected by the scope check below.
@Get(":id/clearance/history")
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@@ -1287,7 +1356,11 @@ export class BookingsController {
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
async getClearanceHistory(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
return this.clearanceEventService.list(id);
}
@@ -1310,6 +1383,12 @@ export class BookingsController {
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
// The transit agent handling this shipment sees the same customer-facing
// slice the customer does — charges actually sent, never the internal
// draft/billing view `list()` returns.
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
return this.clearanceChargeService.listForCustomer(id);
}
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
@@ -1777,8 +1856,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Djibouti-desk write, also filed by the transit agent assigned to this
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
@Post(":id/clearance/delivery-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingDeliveryOrder(
@@ -1788,6 +1869,7 @@ export class BookingsController {
@Body("doCollectedDate") doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
files ?? [],
@@ -1798,7 +1880,7 @@ export class BookingsController {
}
@Post(":id/clearance/release-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingReleaseOrder(
@@ -1807,6 +1889,7 @@ export class BookingsController {
@Body("vesselDepartureDate") vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
files ?? [],
@@ -1821,12 +1904,13 @@ export class BookingsController {
}
@Post(":id/clearance/ro-amendment")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
async requestBookingRoAmendment(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.requestRoAmendment(
id,
dto.note,

View File

@@ -1982,6 +1982,65 @@ export class BookingsService {
}
}
/**
* True when `userId` is a transit agent currently assigned to this booking.
*
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
* assertion guards ~29 call sites, including wagon cancellations, rebooking
* and customer-truck writes. A transit agent must reach the clearance READS
* for the shipments they handle and nothing else, so the two ownership rules
* stay separate and each caller opts in explicitly.
*
* Queried directly rather than through TransitAssignmentsService: that module
* imports BookingsModule, so injecting it here would close an import cycle.
*/
/** Is this portal account a transit agent at all? */
async isTransitAgent(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
const rows: { one: number }[] = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.transit_agents a
WHERE a.user_id = $1 AND a.deleted_at IS NULL
LIMIT 1`,
[userId],
);
return rows.length > 0;
}
async isTransitAgentForBooking(
userId: string | undefined,
bookingId: string,
): Promise<boolean> {
if (!userId) return false;
const rows: { one: number }[] = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.transit_assignments ta
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
WHERE a.user_id = $1
AND ta.booking_id = $2
AND ta.deleted_at IS NULL
AND a.deleted_at IS NULL
LIMIT 1`,
[userId, bookingId],
);
return rows.length > 0;
}
/**
* Authorize a clearance READ on one booking for either audience a portal
* account can be: the owning customer, or a transit agent assigned to it.
*
* Read-only by contract — every caller is a GET. Writes keep using
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
*/
async assertCanReadBookingClearance(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
await this.assertCustomerCanAccessBooking(userId, booking);
}
/**
* Build the customer-facing shipment tracking payload for a booking from the
* train schedule it is assigned to and the live checkpoint log. The caller is

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

View File

@@ -244,6 +244,14 @@ export class TransitAssignmentsService {
assignments: items.length,
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
.length,
// The open half split by status, so the roster's tab counts do not have
// to be derived from a single paginated page.
notStarted: items.filter(
(i) => i.status === TransitAssignmentStatus.NotStarted,
).length,
inProgress: items.filter(
(i) => i.status === TransitAssignmentStatus.InProgress,
).length,
finished: items.filter(
(i) => i.status === TransitAssignmentStatus.Finished,
).length,
@@ -393,6 +401,50 @@ export class TransitAssignmentsService {
return this.findMineById(userId, id);
}
/**
* Make `transitAgentId` the officer working `bookingId`, as the clearance
* desk's "assign transit assignee" step means it.
*
* The booking itself only records the officer's NAME, which is all the
* clearance UI needs; the officer's own portal reads `transit_assignments`.
* This keeps the two in step, and is deliberately forgiving where `create()`
* is strict:
* - assigning the same agent twice is a no-op, not a 409 — the desk may
* re-save the step without meaning to start over;
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
* not sit in the work list of someone who no longer handles it. Finished
* rows stay, since they are that officer's record of work already done.
*/
async ensureAssignment(
bookingId: string,
transitAgentId: string,
assignedByUserId?: string,
): Promise<void> {
const existing =
await this.assignmentsRepository.findByBooking(bookingId);
for (const row of existing) {
if (
row.transitAgentId !== transitAgentId &&
row.status !== TransitAssignmentStatus.Finished
) {
await this.assignmentsRepository.softDelete(row.id);
}
}
if (existing.some((row) => row.transitAgentId === transitAgentId)) return;
await this.assignmentsRepository.create({
bookingId,
transitAgentId,
status: TransitAssignmentStatus.NotStarted,
startedAt: null,
finishedAt: null,
assignedByUserId: assignedByUserId ?? null,
note: null,
});
}
async create(
dto: CreateTransitAssignmentDto,
assignedByUserId?: string,

View File

@@ -42,6 +42,9 @@ const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color:
{
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
// The transit officer assigned to the shipment can post here too — both
// desks see it, and it is never attributed to either of them.
TRANSIT: { label: "Transit agent", color: "grape" },
};
function formatBytes(bytes: number): string {
@@ -99,6 +102,7 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
() => ({
et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length,
transit: documents.filter((d) => d.side === "TRANSIT").length,
shared: documents.filter((d) => d.visibleToCustomer).length,
}),
[documents],
@@ -141,6 +145,11 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
{stats.transit > 0 ? (
<Badge variant="light" color="grape" radius="sm" tt="none">
{stats.transit} from the transit agent
</Badge>
) : null}
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>

View File

@@ -662,6 +662,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardTitleKey: "name",
columns: [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "email", header: "Email", accessorKey: "email" },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{

View File

@@ -71,6 +71,7 @@ import {
} from "./pages/shipping-line";
import {
TransitAgentBookingsPage,
TransitAgentBookingDetailPage,
TransitAgentOverviewPage,
} from "./pages/transit-agent";
import FaqPage from "./pages/support/FaqPage";
@@ -545,6 +546,10 @@ const App = () => {
path="/transit-agent/bookings"
element={<TransitAgentBookingsPage />}
/>
<Route
path="/transit-agent/bookings/:id"
element={<TransitAgentBookingDetailPage />}
/>
</Route>
</Route>
)}

View File

@@ -0,0 +1,166 @@
import {
Badge,
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",
"djibouti",
];
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
customer: "Customer",
gl_et: "GL Ethiopia",
gl_dj: "GL Djibouti",
};
export interface ClearanceWorkflowFilesPanelProps {
files: Freight.ClearanceWorkflowFile[];
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
title?: string;
}
export function ClearanceWorkflowFilesPanel({
files,
onView,
onDownload,
title = "Customs workflow documents",
}: ClearanceWorkflowFilesPanelProps) {
if (files.length === 0) return null;
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
items: files.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
return (
<Card withBorder radius="md" p="md">
<Group gap={10} mb="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<FileText size={16} />
</ThemeIcon>
<Text fw={600} fz={15}>
{title}
</Text>
</Group>
<Stack gap="md">
{grouped.map((group) => (
<Box key={group.category}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
{group.label}
</Text>
<Stack gap={8}>
{group.items.map((item) => (
<WorkflowFileRow
key={item.code}
item={item}
onView={onView}
onDownload={onDownload}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Card>
);
}
function WorkflowFileRow({
item,
onView,
onDownload,
}: {
item: Freight.ClearanceWorkflowFile;
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
}) {
const file = item.file;
if (!file) return null;
const canPreview = isViewable({ name: file.name, url: "" });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{item.label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
{OWNER_LABELS[item.uploadedBy]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,829 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Group,
Modal,
Progress,
Stack,
Stepper,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
FileStack,
Ship,
Upload,
X,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import {
isDeliveryOrderFileCode,
isReleaseOrderFileCode,
} from "@edr/types";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
function toIsoDate(value: Date | null): string | null {
if (!value) return null;
const tz = value.getTimezoneOffset() * 60000;
return new Date(value.getTime() - tz).toISOString().slice(0, 10);
}
/** Today at local midnight — the floor for every date picker here. */
function todayMidnight(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
interface DoDates {
vesselArrival: Date | null;
doCollected: Date | null;
}
/** Both dates present and the DO not collected before the vessel docked. */
function doDatesComplete(d: DoDates): boolean {
if (!d.vesselArrival || !d.doCollected) return false;
return (toIsoDate(d.doCollected) ?? "") >= (toIsoDate(d.vesselArrival) ?? "");
}
/** Minimal multi-file picker — the portal has no shared dropzone component. */
function FilePicker({
label,
description,
accept,
files,
onChange,
}: {
label: string;
description: string;
accept?: string;
files: File[];
onChange: (next: File[]) => void;
}) {
return (
<Stack gap={6}>
<Text fz={13} fw={600}>
{label}
</Text>
<Text fz={11.5} c="dimmed">
{description}
</Text>
<input
type="file"
multiple
accept={accept}
onChange={(e) => onChange(Array.from(e.currentTarget.files ?? []))}
style={{
border: "1px dashed var(--mantine-color-gray-4)",
borderRadius: 8,
padding: 10,
fontSize: 12.5,
background: "var(--mantine-color-gray-0)",
}}
/>
{files.length > 0 ? (
<Group gap={6}>
{files.map((f) => (
<Badge key={f.name} size="sm" variant="light" radius="sm" tt="none">
{f.name}
</Badge>
))}
</Group>
) : null}
</Stack>
);
}
type UploadKind = "do" | "ro";
interface WizardStep {
label: string;
description: string;
done: boolean;
}
type MilestoneRow = { milestoneCode?: string | null; status?: string | null };
/** SKIPPED counts as done — a step that does not apply must not stall the flow. */
function isMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
const m = milestones?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
/**
* The Djibouti-desk actions a transit agent performs on a shipment assigned to
* them: the DO/RO upload, the RO amendment request, and the T1 transit
* documents — laid out as the backoffice's clearance action panel is.
*
* Every button here maps to an endpoint the API authorizes by the assignment
* itself, so a control never promises something the server will refuse. The
* steps are rendered from the clearance payload rather than re-derived.
*/
export function TransitClearanceActionPanel({
bookingId,
clearance,
tradeDirection,
onChanged,
}: {
bookingId: string;
clearance?: Freight.ClearanceView;
tradeDirection?: string | null;
onChanged: () => void;
}) {
const queryClient = useQueryClient();
const [uploadKind, setUploadKind] = useState<UploadKind | null>(null);
const [amendOpen, setAmendOpen] = useState(false);
const [t1Open, setT1Open] = useState(false);
const isImport = tradeDirection === "IMPORT";
const workflowFiles = clearance?.workflowFiles ?? [];
const hasDo = workflowFiles.some(
(f) => isDeliveryOrderFileCode(f.code) && f.file,
);
const hasRo = workflowFiles.some(
(f) => isReleaseOrderFileCode(f.code) && f.file,
);
const roHoldReason = clearance?.roHoldReason ?? null;
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] });
void queryClient.invalidateQueries({
queryKey: ["transit-clearance-history"],
});
onChanged();
};
// ── Steps ────────────────────────────────────────────────────────────────
// The same wizards the GL Djibouti desk sees: 12 import steps, 11 export.
// Every `done` reads off the clearance payload — milestones, booking
// milestones, or a server-set flag — so a step can never claim to be
// complete when the server does not consider it so.
// This page is always booking-scoped, and for a booking the clearance view's
// milestones ARE the booking milestones — the GL detail page passes the same
// array for both on its `kind === "booking"` branch.
const ms = clearance?.milestones;
const done = (code: string) => isMilestoneDone(ms, code);
const bookingDone = (code: string) => isMilestoneDone(ms, code);
const dutyRequired = clearance?.dutyRequired ?? false;
const steps: WizardStep[] = isImport
? [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Draft declaration",
description: "Customer accepts the estimated price",
done: done("DRAFT_DECLARATION_ACCEPTED") || done("DECLARED"),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads declaration documents",
done: done("DECLARED"),
},
{
label: "Duty & tax",
description: "Amount advised and notice attached",
// A shipment with no duty due skips this rather than stalling on it.
done: !dutyRequired || done("DUTY_TAXES_ADVISED"),
},
{
label: "Customer payment",
description: "Customer uploads the duty payment slip",
done: !dutyRequired || done("DUTY_TAX_PAID"),
},
{
label: "Transit Permit",
description: "Transit permit documents uploaded",
done: done("TRANSIT_PERMIT_UPLOADED"),
},
{
label: "Finalize pre-clearance",
description: "GL Ethiopia hands off to GL Djibouti",
done: Boolean(clearance?.preClearanceFinalized),
},
{
label: "Delivery Order",
description: "You upload the DO with its collection dates",
done: done("DO_COLLECTED"),
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
done: Boolean(clearance?.t1) || bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
label: "Freight payment",
description: "Customer pays the train and service charges",
done: bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
label: "Gate pass",
description: "Secured after payment and wagon allocation",
done: Boolean(clearance?.gatepassGranted),
},
]
: [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads — releases the export",
done: done("DECLARED"),
},
{
label: "Release Order",
description: "You upload the RO with the vessel departure date",
done: done("RELEASE_ORDER_SECURED") || hasRo,
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
done: bookingDone("FREIGHT_PAYMENT_SETTLED") || Boolean(clearance?.t1),
},
{
label: "Payment & wagon allocation",
description: "Customer pays; operations allocates wagons",
done:
bookingDone("FREIGHT_PAYMENT_SETTLED") &&
(bookingDone("WAGON_ALLOCATED") ||
Boolean(clearance?.train?.wagonAllocated)),
},
{
label: "Transport document",
description: "GL Ethiopia uploads after wagon allocation",
done: bookingDone("EXPORT_TRANSPORT_ISSUED"),
},
{
label: "Train to Djibouti",
description: "Departure and arrival",
done: Boolean(clearance?.train?.arrivedAt),
},
{
label: "Accept T1",
description: "You close the T1 once the train arrives",
done: Boolean(clearance?.t1Closed),
},
{
label: "Gate pass",
description: "Secured on the train schedule after arrival",
done: Boolean(clearance?.gatepassGranted),
},
{
label: "Offload",
description: "Cargo comes off the train",
done: Boolean(clearance?.offloaded ?? clearance?.offload?.offloaded),
},
];
// The wizard sits on the FIRST step not yet done — matching the GL desk's
// "Step N of M", which counts the step being worked on, not the ones behind it.
const firstPending = steps.findIndex((s) => !s.done);
const activeStep = firstPending === -1 ? steps.length : firstPending;
const percent = Math.round((activeStep / steps.length) * 100);
const currentStep = steps[activeStep] ?? null;
return (
<>
<Card withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
{/* Header: title, "Step N of M", and the progress bar — the GL desk's
own wizard chrome. */}
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={12}
style={{ borderBottom: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Ship size={16} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={14} c="edr-text">
{isImport
? "Import pre-booking clearance"
: "Export customs clearance"}
</Text>
<Text fz={11.5} c="dimmed">
Step {Math.min(activeStep + 1, steps.length)} of {steps.length}
{currentStep ? ` · ${currentStep.label}` : ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress
value={percent}
color="edr-green"
radius="xl"
size={6}
w={110}
/>
<Text fz={11.5} c="#67788A" fw={600}>
{percent}%
</Text>
</Group>
</Group>
{/* Whose desk the flow is sitting on right now. */}
{clearance?.nextAction ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{
background: "#E9F1FC",
borderBottom: "1px solid #EFF3F7",
}}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text
fz={10.5}
fw={700}
lts="0.4px"
c="#1D6FD1"
style={{ flexShrink: 0 }}
>
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{clearance.nextAction.action}
</Text>
</Group>
) : null}
<Box p="md">
{roHoldReason ? (
<Alert
color="red"
variant="light"
mb="sm"
icon={<AlertTriangle size={16} />}
title="RO amendment hold"
>
{roHoldReason}
</Alert>
) : null}
<Stepper
active={activeStep}
orientation="vertical"
size="sm"
iconSize={26}
allowNextStepsSelect={false}
mb="md"
>
{steps.map((s) => (
<Stepper.Step
key={s.label}
label={s.label}
description={s.description}
icon={s.done ? <CheckCircle2 size={14} /> : undefined}
/>
))}
</Stepper>
<Stack gap={8}>
{isImport ? (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={() => setUploadKind("do")}
>
{hasDo ? "Replace Delivery Order" : "Upload Delivery Order"}
</Button>
) : (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={() => setUploadKind("ro")}
>
{hasRo ? "Replace Release Order" : "Upload Release Order"}
</Button>
)}
<Button
variant="light"
radius="md"
leftSection={<FileStack size={15} />}
onClick={() => setT1Open(true)}
>
Upload T1 documents
</Button>
{!isImport ? (
<Button
variant="subtle"
color="red"
radius="md"
leftSection={<AlertTriangle size={15} />}
onClick={() => setAmendOpen(true)}
>
Request RO amendment
</Button>
) : null}
</Stack>
</Box>
</Card>
<UploadOrderModal
kind={uploadKind}
bookingId={bookingId}
replaceMode={uploadKind === "do" ? hasDo : hasRo}
vesselArrivalDate={clearance?.vesselArrivalDate ?? null}
doCollectedDate={clearance?.doCollectedDate ?? null}
onClose={() => setUploadKind(null)}
onSuccess={refresh}
/>
<T1UploadModal
opened={t1Open}
bookingId={bookingId}
onClose={() => setT1Open(false)}
onSuccess={refresh}
/>
<RoAmendmentModal
opened={amendOpen}
bookingId={bookingId}
onClose={() => setAmendOpen(false)}
onSuccess={refresh}
/>
</>
);
}
/** DO/RO upload — mirrors the backoffice's GlClearanceUploadModal. */
function UploadOrderModal({
kind,
bookingId,
replaceMode,
vesselArrivalDate,
doCollectedDate,
onClose,
onSuccess,
}: {
kind: UploadKind | null;
bookingId: string;
replaceMode: boolean;
vesselArrivalDate: string | null;
doCollectedDate: string | null;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(null);
const [doDates, setDoDates] = useState<DoDates>({
vesselArrival: vesselArrivalDate ? new Date(vesselArrivalDate) : null,
doCollected: doCollectedDate ? new Date(doCollectedDate) : null,
});
const isDo = kind === "do";
const today = todayMidnight();
const doMin =
doDates.vesselArrival && doDates.vesselArrival > today
? doDates.vesselArrival
: today;
const outOfOrder =
Boolean(doDates.vesselArrival && doDates.doCollected) &&
!doDatesComplete(doDates);
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: async () => {
if (isDo) {
return transitAssignmentsService.uploadDeliveryOrder(bookingId, files, {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
});
}
return transitAssignmentsService.uploadReleaseOrder(
bookingId,
files,
toIsoDate(vesselDate)!,
);
},
onSuccess: (result) => {
// The RO endpoint answers with a hold instead of an error when the vessel
// date is too soon — say so rather than reporting a clean success.
if (result && typeof result === "object" && "hold" in result && result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(
isDo
? replaceMode
? "Delivery Order updated"
: "Delivery Order uploaded"
: replaceMode
? "Release Order updated"
: "Release Order uploaded",
);
}
setFiles([]);
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
const blocked =
files.length === 0 ||
(isDo ? !doDatesComplete(doDates) : !vesselDate);
return (
<Modal
opened={kind != null}
onClose={close}
radius="md"
size="md"
title={
<Group gap={8}>
<Ship size={18} />
<Text fw={700}>
{isDo ? "Upload Delivery Order" : "Upload Release Order"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{isDo
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
: "Upload the Release Order and confirm the vessel departure date."}
</Text>
{isDo ? (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
label="Vessel arrival date"
placeholder="Select date"
value={doDates.vesselArrival}
onChange={(v) =>
setDoDates((d) => ({
...d,
vesselArrival: v ? new Date(v) : null,
}))
}
minDate={today}
size="sm"
required
withAsterisk
/>
<DateInput
label="DO collected date"
placeholder="Select date"
value={doDates.doCollected}
onChange={(v) =>
setDoDates((d) => ({
...d,
doCollected: v ? new Date(v) : null,
}))
}
minDate={doMin}
size="sm"
required
withAsterisk
error={
outOfOrder
? "Cannot be before the vessel arrival date."
: undefined
}
/>
</Group>
) : (
<DateInput
label="Vessel departure date"
placeholder="Select date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={today}
size="sm"
required
/>
)}
<FilePicker
label={isDo ? "Delivery Order files" : "Release Order files"}
description={
isDo
? "Any file type. Add as many files as needed."
: "PDF or image. Add as many files as needed."
}
accept={isDo ? undefined : "application/pdf,image/*"}
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={close}
disabled={submit.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={blocked}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
{replaceMode
? isDo
? "Replace DO"
: "Replace RO"
: isDo
? "Upload DO"
: "Upload RO"}
</Button>
</Group>
</Stack>
</Modal>
);
}
function T1UploadModal({
opened,
bookingId,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.uploadT1Documents(bookingId, files),
onSuccess: () => {
toast.success("T1 documents uploaded");
setFiles([]);
onSuccess();
onClose();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
return (
<Modal
opened={opened}
onClose={onClose}
radius="md"
size="md"
title={
<Group gap={8}>
<FileStack size={18} />
<Text fw={700}>Upload T1 transit documents</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
T1 documents are filed after wagon allocation and lock once the train
departs.
</Text>
<FilePicker
label="T1 documents"
description="Any file type. Add as many files as needed."
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
Upload
</Button>
</Group>
</Stack>
</Modal>
);
}
function RoAmendmentModal({
opened,
bookingId,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onSuccess: () => void;
}) {
const [note, setNote] = useState("");
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.requestRoAmendment(bookingId, note.trim()),
onSuccess: () => {
toast.success("RO amendment requested");
setNote("");
onSuccess();
onClose();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Request failed"),
});
return (
<Modal
opened={opened}
onClose={onClose}
radius="md"
size="md"
title={
<Group gap={8}>
<AlertTriangle size={18} />
<Text fw={700}>Request RO amendment</Text>
</Group>
}
>
<Stack gap="md">
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
This puts the shipment on hold until GL Ethiopia amends the Release
Order.
</Alert>
<Textarea
label="What needs to change?"
placeholder="Describe the correction needed on the Release Order"
minRows={3}
autosize
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
withAsterisk
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="red"
loading={submit.isPending}
disabled={note.trim().length === 0}
leftSection={<X size={16} />}
onClick={() => submit.mutate()}
>
Request amendment
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,2 +1,3 @@
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";
export { default as TransitAgentBookingDetailPage } from "./TransitAgentBookingDetailPage";

View File

@@ -0,0 +1,150 @@
/*
* Scoped to .edr-transit-table — the DataTable container div on the
* transit agent bookings list. Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-transit-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-transit-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-transit-table th,
.edr-transit-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
* Cap those two columns and let their content wrap onto 2+ lines so a very long
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
* stacks inside its own cell instead of shoving the next column off-screen.
* Everything below the header row so the header labels still sit on one line.
*/
.edr-transit-table tbody td:not([colspan]):nth-child(1) {
max-width: 240px;
white-space: normal;
}
.edr-transit-table tbody td:not([colspan]):nth-child(2) {
max-width: 200px;
white-space: normal;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-transit-table .mantine-Badge-root {
max-width: none;
}
/*
* Opt-out for long free text (company/customer names). The blanket nowrap rule
* above keeps every cell on one line so columns size to content; a very long
* name would otherwise force the column absurdly wide. Mark such text with
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
*/
.edr-transit-table .cell-wrap,
.edr-transit-table .mantine-Group-root > .cell-wrap {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
max-width: 100%;
line-height: 1.3;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-transit-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-transit-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-transit-table th:last-child,
.edr-transit-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-transit-table td:last-child:not([colspan]) {
background: var(--mantine-color-body);
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-transit-table tbody tr:hover td:last-child:not([colspan]) {
background: #f7fbf9;
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-transit-table th:last-child {
background: var(--mantine-color-gray-0);
z-index: 3;
}
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
.edr-transit-table thead th {
height: 38px;
padding-top: 0;
padding-bottom: 0;
background: var(--mantine-color-gray-0);
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-transit-table tbody td:not([colspan]) {
height: 64px;
padding-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-transit-table tbody tr:last-child td:not([colspan]) {
border-bottom: 0;
}
.edr-transit-table tbody tr:hover td {
background: #f7fbf9;
}

View File

@@ -1,3 +1,5 @@
import type { Freight } from "@edr/types";
import { client } from "@/utils/api";
const BASE = "/api/transit-assignments/my";
@@ -87,6 +89,8 @@ export interface TransitStats {
totals: {
assignments: number;
open: number;
notStarted: number;
inProgress: number;
finished: number;
readyForDocuments: number;
documents: number;
@@ -169,4 +173,119 @@ export const transitAssignmentsService = {
const { data } = await client.post(`${BASE}/${id}/submit`, input);
return data.data ?? data;
},
// ── Clearance reads for an assigned booking ──────────────────────────────
// These hit the shared booking/contract endpoints, not `/my`. The API scopes
// them to shipments this agent is assigned to and 404s anything else, so the
// booking id below is safe to pass straight through.
/** Clearance action history — reviews, workflow steps, charges (newest first). */
clearanceHistory: async (
bookingId: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/clearance/history`,
);
return data.data ?? data;
},
/** GL Ethiopia ↔ GL Djibouti shared document thread (read-only here). */
glExchange: async (
bookingId: string,
): Promise<Freight.GlExchangeDocument[]> => {
const { data } = await client.get(`/api/gl-exchange/${bookingId}`);
return data.data ?? data;
},
/** Cargo exception / damage reports logged against the shipment. */
incidents: async (bookingId: string): Promise<Freight.IClearanceIncident[]> => {
const { data } = await client.get(
`/api/contracts/bookings/${bookingId}/incidents`,
);
return data.data ?? data;
},
// ── Djibouti-desk writes, filed by the assigned agent ────────────────────
// The API gates these on the assignment (see `assertPortalClearanceAccess`),
// so an agent can only ever write to a shipment handed to them.
/** Delivery Order (imports). Both dates are required by the API. */
uploadDeliveryOrder: async (
bookingId: string,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
await client.post(
`/api/bookings/${bookingId}/clearance/delivery-order`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
/**
* Release Order (exports). The API may answer with a HOLD rather than an
* error when the vessel date is too soon — surfaced, not swallowed.
*/
uploadReleaseOrder: async (
bookingId: string,
files: File[],
vesselDepartureDate: string,
): Promise<{ hold?: boolean; holdReason?: string | null }> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("vesselDepartureDate", vesselDepartureDate);
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/release-order`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
/** Ask GL Ethiopia to amend the RO — puts the shipment on hold. */
requestRoAmendment: async (
bookingId: string,
note: string,
): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/ro-amendment`, {
note,
});
},
/**
* Share a document with both GL desks. Posted as the TRANSIT side, so it is
* never credited to a desk. `visibleToCustomer` additionally surfaces it in
* the customer's own portal.
*/
shareExchangeDocument: async (
bookingId: string,
input: { file: File; title: string; visibleToCustomer: boolean },
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
form.append("file", input.file);
form.append("title", input.title.trim());
form.append("visibleToCustomer", String(input.visibleToCustomer));
const { data } = await client.post(`/api/gl-exchange/${bookingId}`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
/** T1 transit documents; locked once the train departs. */
uploadT1Documents: async (
bookingId: string,
files: File[],
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(
`/api/contracts/bookings/${bookingId}/t1-documents`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
};

View File

@@ -1010,8 +1010,11 @@ export interface GlExchangeDocument {
/** Booking or contract id the document is attached to. */
entityId: string;
title: string;
/** Desk that uploaded it. */
side: "ET" | "DJ";
/**
* Who uploaded it: either Global Logistics desk, or the transit agent
* assigned to the shipment. Both desks see all three.
*/
side: "ET" | "DJ" | "TRANSIT";
visibleToCustomer: boolean;
uploadedById: string | null;
uploadedByName: string | null;