changes on transit agent

This commit is contained in:
marshal
2026-09-08 06:58:14 +00:00
parent 92697386e4
commit 850e0753d2
14 changed files with 1528 additions and 115 deletions

View File

@@ -417,12 +417,14 @@ export class BookingLifecycleNotifierService {
b: Booking,
agent: { id: string; name: string },
previous: string | null,
/** Who named the officer — GL Djibouti unless the clearing agent did. */
by = 'GL Djibouti',
): void {
const assignee = agent.name;
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
? `${by} changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
: `${by} assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
@@ -441,7 +443,7 @@ export class BookingLifecycleNotifierService {
{
title: 'New shipment assigned to you',
body:
`GL Djibouti assigned shipment ${b.reference} to ${assignee} for transit. ` +
`${by} assigned shipment ${b.reference} to ${assignee} for transit. ` +
`Open it in the portal to see what is needed.`,
officerLink: `/transit-agent/bookings/${b.id}`,
forwarderLink: '/forwarder/assigned-bookings',

View File

@@ -878,6 +878,33 @@ export class BookingsController {
}
}
/**
* Gate a GL review action that the assigned clearing agent may also take.
*
* On a without-customs booking the freight forwarder the customer assigned
* clears customs in GL's place: it reviews the customer's documents, asks
* for missing ones and finalizes. Staff pass on their permission; a portal
* caller must be assigned to THIS booking and the booking must be one GL
* does not clear — a customs booking stays with Global Logistics.
*/
private async assertStaffOrClearingAgent(
bookingId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const booking = await this.bookingsService.findById(bookingId);
if (
booking.customsClearingEnabled ||
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
@@ -1372,16 +1399,24 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent assigned to a without-customs booking — see
// assertStaffOrClearingAgent.
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@MixedAudience(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
summary:
"GL — or the assigned clearing agent — reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.bookings.reviewDocuments,
);
const booking = await this.transitionService.reviewDocument(
id,
dto.fileKey,
@@ -1393,16 +1428,21 @@ export class BookingsController {
}
@Post(":id/clearance/doc-requests")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL asks the customer for additional clearance document(s) shown on the portal with author and time",
"GL — or the assigned clearing agent — asks the customer for additional clearance document(s); shown on the portal with author and time",
})
async requestAdditionalDocuments(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.contracts.clearanceEtActions,
);
await this.transitionService.requestAdditionalDocuments(
id,
note,
@@ -1686,15 +1726,20 @@ export class BookingsController {
}
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@MixedAudience(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
"GL — or the assigned clearing agent — finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.bookings.finalizeClearance,
);
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
@@ -1721,21 +1766,36 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (freight forwarder) the customer assigned: on a
// without-customs booking there is no GL Djibouti desk in the loop, so the
// forwarder names the Djibouti officer itself. Any other portal caller is
// rejected below, hidden behind a NotFound like the ownership checks.
@Post(":id/clearance/transit-assignee/assign")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
"GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns",
"GL Djibouti — or the assigned clearing agent — picks the Djibouti transit officer from the roster; calling again reassigns",
})
async assignBookingTransitAssignee(
@Param("id", ParseUUIDPipe) id: string,
@Body("transitAgentId", ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.contracts.clearanceDjActions,
);
if (
!isStaff &&
!(await this.bookingsService.isTransitAgentForBooking(user?.id, id))
) {
throw new NotFoundException(`Booking ${id} not found`);
}
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
{ byAssignedAgent: !isStaff },
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -29,6 +29,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
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';
@@ -581,19 +582,33 @@ export class BookingClearanceService {
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*
* `byAssignedAgent`: the clearing agent the customer assigned (a freight
* forwarder) names the Djibouti officer itself. That is a without-customs
* booking with no phased workflow and no GL request to answer, so neither
* gate applies — only that the officer is an active Djiboutian entry. The
* caller has already established the actor is assigned to this booking.
*/
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
opts: { byAssignedAgent?: boolean } = {},
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
const booking = opts.byAssignedAgent
? await this.bookingsService.findById(bookingId)
: await this.loadBooking(bookingId);
if (!opts.byAssignedAgent && !booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
if (opts.byAssignedAgent && agent.country !== TransitAgentCountry.Djibouti) {
throw new BadRequestException(
`${agent.name} is not a Djibouti transit agent — pick one from the Djibouti roster.`,
);
}
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
@@ -619,7 +634,14 @@ export class BookingClearanceService {
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent, previous);
this.notifier.transitAssigneeAssigned(
booking,
agent,
previous,
opts.byAssignedAgent
? booking.customsClearingAgent ?? 'The clearing agent'
: undefined,
);
return this.bookingsService.findById(bookingId);
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { BadRequestException, ConflictException } from "@nestjs/common";
import { TrainSchedulingService } from "./services/train-scheduling.service";
@@ -266,3 +266,121 @@ describe('dispatchSchedule — the whole consist travels, not just loaded slots'
expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']);
});
});
/**
* Shedding is a WRITE that commits on its own, so it must be the last thing
* before the departure write — not the first thing dispatch does. When it ran
* first, a dispatch the wagon-yard gate then rejected (409) had already pulled
* the unticked bookings off the train: MANUAL_ONLY, allocations gone, customer
* told to rebook — for a train that never left (BK-2026-000341).
*/
describe('dispatchSchedule — sheds left-behind boarders only once every gate has passed', () => {
type Slot = {
physicalWagonId: string | null;
allocations: Array<{ bookingId: string }>;
};
type OutElsewhereQuery = { where: { id: { value: string[] } } };
/** The real dispatchSchedule over stubs for everything around the gates. */
const buildService = (opts: { yardGate?: () => Promise<void>; slots?: Slot[] }) => {
const events: string[] = [];
let outElsewhere: OutElsewhereQuery | null = null;
const schedule = {
id: 'sched-1',
status: 'SCHEDULED',
originStationId: 'yard-a',
stationWorkLogs: {
'yard-a': {
loading: { startedAt: '2026-09-08T05:00:00Z', endedAt: '2026-09-08T06:00:00Z' },
},
},
trainSet: { wagons: opts.slots ?? [] },
scheduleBookings: [],
};
const svc = Object.create(TrainSchedulingService.prototype) as TrainSchedulingService;
const stubs = svc as unknown as Record<string, unknown>;
stubs.trainSchedulesRepository = {
findByIdWithFullGraph: async () => {
events.push('load-graph');
return schedule;
},
};
stubs.unloadedOriginBoarderIds = async () => ['b1', 'b2'];
stubs.unassignBooking = async (_scheduleId: string, bookingId: string) => {
events.push(`unassign:${bookingId}`);
};
stubs.assertImportDjiboutiMayDepart = async () => undefined;
stubs.locomotivesOfTrainSet = () => [];
stubs.assertLocomotivesNotDispatchedElsewhere = async () => undefined;
stubs.assertPlannedYardsAligned = async () => {
events.push('gate:yards');
await opts.yardGate?.();
};
stubs.assertNoPartiallyLoadedBookings = async () => undefined;
stubs.dataSource = {
getRepository: () => ({
find: async (query: OutElsewhereQuery) => {
outElsewhere = query;
return [];
},
}),
transaction: async () => {
events.push('depart');
},
};
stubs.isImportDjiboutiSchedule = () => false;
stubs.emitWindowState = async () => undefined;
stubs.notifyScheduleBookings = async () => undefined;
stubs.getTrainScheduleById = async () => ({ trainNumber: '9001' });
return { svc, events, outElsewhere: () => outElsewhere };
};
it('leaves every unticked boarder on the train when a gate rejects the dispatch', async () => {
const { svc, events } = buildService({
yardGate: async () => {
throw new ConflictException(
'Cannot dispatch: 39 wagon(s) are not at the yard this schedule planned them for',
);
},
});
await expect(
svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events).toEqual(['load-graph', 'gate:yards']);
});
it('sheds after the last gate and before the departure write', async () => {
const { svc, events } = buildService({});
await svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1');
expect(events).toEqual([
'load-graph',
'gate:yards',
'unassign:b1',
'unassign:b2',
'load-graph',
'depart',
]);
});
it('sheds nobody when the client omits the list', async () => {
const { svc, events } = buildService({});
await svc.dispatchSchedule('sched-1', {}, 'user-1');
expect(events).toEqual(['load-graph', 'gate:yards', 'depart']);
});
it('judges the out-on-another-train gate on the wagons that will actually depart', async () => {
const slots: Slot[] = [
{ physicalWagonId: 'w1', allocations: [{ bookingId: 'b1' }] }, // shed-only → released
{ physicalWagonId: 'w2', allocations: [{ bookingId: 'b3' }] },
{ physicalWagonId: 'w3', allocations: [{ bookingId: 'b1' }, { bookingId: 'b3' }] },
];
const ticked = buildService({ slots });
await ticked.svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1');
expect(ticked.outElsewhere()?.where.id.value).toEqual(['w2', 'w3']);
// No list → nothing is shed, so every pinned slot is judged.
const legacy = buildService({ slots });
await legacy.svc.dispatchSchedule('sched-1', {}, 'user-1');
expect(legacy.outElsewhere()?.where.id.value).toEqual(['w1', 'w2', 'w3']);
});
});

View File

@@ -3169,22 +3169,20 @@ export class TrainSchedulingService {
// deallocated from its wagon and returned to the booking pool — so the
// origin auto-load below only ever touches confirmed cargo. Government
// bookings cannot be unassigned and keep the historic auto-load.
//
// DECIDED here, SHED only after every gate below has passed. Unassign
// commits in its own transaction, so shedding first meant a dispatch the
// next gate rejected (loading window, wagons off their planned yard, …)
// had already stripped the bookings off the train — MANUAL_ONLY,
// allocations gone, customer told to rebook — while the train never left.
// Staff clear the blocker and dispatch again; the dialog re-sends the list.
let leftBehind: string[] = [];
if (dto.loadedBookingIds) {
const keep = new Set(dto.loadedBookingIds);
const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId);
const leftBehind = candidates.filter((id) => !keep.has(id));
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
leftBehind = candidates.filter((id) => !keep.has(id));
}
const shed = new Set(leftBehind);
// Dispatch requires the origin's loading window to be COMPLETE: started
// and ended. Not started or still open both block — a train departs only
// after loading was formally opened and closed.
@@ -3220,8 +3218,14 @@ export class TrainSchedulingService {
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
// Same rule for wagons: many schedules may pin the same wagon, but it can
// only be OUT on one dispatched train at a time.
// only be OUT on one dispatched train at a time. Judged on the train that
// will actually depart: shedding releases every slot left with no
// allocation, so those slots' wagons are not this train's concern.
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.filter(
(slot) =>
shed.size === 0 || (slot.allocations ?? []).some((a) => !shed.has(a.bookingId)),
)
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (pinnedPhysicalIds.length) {
@@ -3245,6 +3249,19 @@ export class TrainSchedulingService {
action: 'dispatch',
});
// Every gate passed — the train is leaving. Shed the unticked boarders now.
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {

View File

@@ -74,6 +74,20 @@ export class TransitAgentsController {
return this.transitAgentsService.findForwarderOptions();
}
/**
* The Djibouti roster, id + name only, for the clearing agent on a booking
* to name the transit officer. Also before `:id` for the same reason.
*/
@Get("djibouti-options")
@PortalCustomer()
@ApiOperation({
summary:
"List active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to",
})
findDjiboutiOptions() {
return this.transitAgentsService.findDjiboutiOptions();
}
@Get(":id")
@RuleEngineView("transit-agents")
@ApiOperation({ summary: "Get a transit agent by ID" })

View File

@@ -41,6 +41,19 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
});
}
/**
* The Djibouti roster, `{ id, name }` only, for the clearing agent on a
* booking to hand the transit leg to — served to portal customers, so no
* contact details. Suspended officers are left out.
*/
findDjiboutiOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.repository.find({
select: { id: true, name: true },
where: { isActive: true, country: TransitAgentCountry.Djibouti },
order: { name: "ASC" },
});
}
/** The transit agent signed in as `userId`, or null for any other account. */
findByUserId(userId: string): Promise<TransitAgent | null> {
return this.repository.findOne({ where: { userId } });

View File

@@ -114,6 +114,11 @@ export class TransitAgentsService {
return this.transitAgentsRepository.findForwarderOptions();
}
/** The Djibouti roster an assigned clearing agent picks the transit officer from. */
findDjiboutiOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.transitAgentsRepository.findDjiboutiOptions();
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {

View File

@@ -770,10 +770,19 @@ export class TransitAssignmentsService {
const existing =
await this.assignmentsRepository.findByBooking(bookingId);
// A shipment carries one agent PER COUNTRY: the Ethiopian clearing agent
// (the forwarder the customer picked) and the Djibouti transit officer
// work side by side. Only a predecessor in the same role is retired; the
// other country's row is left alone, or naming the officer would knock
// the forwarder off the booking it is clearing.
const incoming = await this.transitAgentsRepository.findById(transitAgentId);
for (const row of existing) {
if (
row.transitAgentId !== transitAgentId &&
row.status !== TransitAssignmentStatus.Finished
row.status !== TransitAssignmentStatus.Finished &&
(!incoming ||
!row.transitAgent ||
row.transitAgent.country === incoming.country)
) {
await this.assignmentsRepository.softDelete(row.id);
}

View File

@@ -229,6 +229,8 @@ export const URL_CONSTANTS = {
TRANSIT_AGENTS_API: {
/** Active Ethiopian transit agents (id + name) a forwarder can register as. */
FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options",
/** Active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to. */
DJIBOUTI_OPTIONS: "/api/transit-agents/djibouti-options",
},
PORTAL_CONTENT: {
PUBLIC: "/api/support-content",

View File

@@ -1,32 +1,58 @@
import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Grid,
Group,
Loader,
Paper,
RingProgress,
Select,
SimpleGrid,
Stack,
Tabs,
Text,
Textarea,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
CircleDot,
ClipboardList,
Clock,
Clock3,
FilePlus2,
FileText,
History,
MessageSquare,
PackageCheck,
ShipWheel,
UserCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate, useParams } from "react-router-dom";
import { bookingDocNounCapitalized } from "@/pages/bookings/clearance/bookingNextAction";
import useAuth from "@/hooks/useAuth";
import { api } from "@/services/api";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentStatus,
} from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { AssignedBookingDocumentsLoader } from "./AssignedBookingDocuments";
import { ForwarderDocumentReview } from "./ForwarderDocumentReview";
const LIST_PATH = "/forwarder/assigned-bookings";
@@ -57,28 +83,73 @@ function formatDate(value?: string | null): string {
});
}
function formatDateTime(value: string): string {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
}
function apiMessage(e: Error, fallback: string): string {
const data = (e as { response?: { data?: { message?: string | string[] } } })
.response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
return message || e.message || fallback;
}
/**
* One booking a customer assigned to this forwarder, with the customer's
* import/export document grid on it.
* The clearing agent's working page for one assigned booking — the portal
* counterpart of the GL Ethiopia clearance page in the backoffice.
*
* The forwarder clears customs on the customer's behalf, so it uploads the
* booking's clearance documents through the same flow and endpoint the
* customer uses — the API admits the assigned agent to both. Both parties can
* upload; what the forwarder never does here is pick the shipment day, which
* stays the customer's decision (`uploadOnly`).
*
* Until the roster role is approved the API hides the booking, so the page
* shows the assignment's own facts and says why the documents are not there.
* Same shape as that page: header with the review state, a KPI strip over the
* customer's documents, the review grid on the left (approve, query, upload
* on the customer's behalf, finalize), and the side column with the request
* for more documents, the Djibouti transit officer, and the booking facts.
* The History tab is the clearance trail. Uploading, reviewing and assigning
* are locked until the roster role is approved, since the API hides the
* booking until then.
*/
export default function AssignedBookingDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { assignedBookingsUnlocked } = useAuth();
const assignmentQuery = useQuery({
queryKey: ["transit-assignments", "my", id],
queryFn: () => transitAssignmentsService.getById(id!),
enabled: Boolean(id),
});
const assignment = assignmentQuery.data;
const bookingId = assignment?.bookingId;
const bookingQuery = useQuery({
...api.bookings.get.queryOptions({ input: { id: bookingId ?? "" } }),
enabled: Boolean(bookingId) && assignedBookingsUnlocked,
});
const booking = bookingQuery.data;
const clearanceQuery = useQuery({
...api.bookings.getClearance.queryOptions({
input: { id: bookingId ?? "" },
}),
enabled: Boolean(bookingId) && assignedBookingsUnlocked,
});
const clearance = clearanceQuery.data;
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
const awaitingReview = docs.filter(
(d) => d.file && d.reviewStatus !== "APPROVED",
).length;
return { total, approved, queried, pending, pct, awaitingReview };
}, [clearance]);
if (assignmentQuery.isPending) {
return (
@@ -108,81 +179,268 @@ export default function AssignedBookingDetailPage() {
}
const b = assignment.booking;
const reference = b?.reference ?? booking?.reference ?? "Assigned booking";
const direction = b?.tradeDirection ?? booking?.tradeDirection ?? null;
const statusMeta = STATUS_META[assignment.status];
const origin =
booking?.originYard?.label ?? booking?.originYard?.code ?? null;
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? null;
const refresh = () => {
void bookingQuery.refetch();
void clearanceQuery.refetch();
};
const kpis = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap="lg">
<BackButton onClick={() => navigate(LIST_PATH)} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="sm" align="center">
<div className="flex size-10 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<PackageCheck size={20} />
</div>
<Box>
<Title order={2}>{b?.reference ?? "Assigned booking"}</Title>
<Group gap={4} wrap="nowrap" align="center">
<Building2 size={12} className="shrink-0 text-edr-muted" />
<Text c="edr-muted" size="sm">
{assignment.customerName ?? "—"}
</Text>
</Group>
</Box>
</Group>
<Group gap="xs">
{b?.tradeDirection ? (
<Badge size="sm" variant="light" radius="sm" color="blue">
{prettyStatus(b.tradeDirection)}
</Badge>
) : null}
{b?.status ? (
<Badge size="sm" variant="light" radius="sm" color="gray">
{prettyStatus(b.status)}
</Badge>
) : null}
<Badge size="sm" variant="light" radius="sm" color={statusMeta.color}>
{statusMeta.label}
</Badge>
</Group>
</Group>
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="xl" wrap="wrap">
<Fact
icon={<Clock3 size={14} />}
label="Assigned"
value={formatDate(assignment.assignedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Started"
value={formatDate(assignment.startedAt)}
/>
<Fact
icon={<Clock3 size={14} />}
label="Finished"
value={formatDate(assignment.finishedAt)}
/>
</Group>
{assignment.note ? (
<Text fz={13} c="edr-text" mt="sm" style={{ whiteSpace: "pre-wrap" }}>
{assignment.note}
{/* ── Header ─────────────────────────────────────────────── */}
<Stack gap="sm">
<Group gap={6}>
<Anchor
fz={12.5}
c="edr-muted"
onClick={() => navigate(LIST_PATH)}
style={{ cursor: "pointer" }}
>
Assigned bookings
</Anchor>
<Text fz={12.5} c="edr-muted">
/
</Text>
) : null}
</Card>
<Text fz={12.5} c="edr-text">
{reference}
</Text>
</Group>
<Card withBorder shadow="sm" radius="lg" p="md">
<Title order={4} mb="md">
{b
? bookingDocNounCapitalized({
customsClearingEnabled: false,
tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT",
})
: "Documents"}
</Title>
<AssignedBookingDocumentsLoader assignment={assignment} />
</Card>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<Group gap="sm" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Button
variant="subtle"
color="gray"
px={8}
onClick={() => navigate(LIST_PATH)}
aria-label="Go back"
>
<ArrowLeft size={18} />
</Button>
<div style={{ minWidth: 0, maxWidth: 720 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} className="truncate" style={{ minWidth: 0 }}>
{reference}
</Title>
{direction ? (
<Badge
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{prettyStatus(direction)}
</Badge>
) : null}
{booking?.status ? (
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(booking.status)}
</Badge>
) : null}
<Badge variant="light" color={statusMeta.color} radius="sm">
{statusMeta.label}
</Badge>
{clearance ? (
stats.awaitingReview > 0 ? (
<Badge
variant="filled"
color="orange"
radius="sm"
leftSection={<Clock size={13} />}
>
{stats.awaitingReview} needs approval
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
) : null}
</Group>
<Group gap={10} wrap="wrap" mt={4}>
<Group gap={5} wrap="nowrap">
<Building2 size={12} className="shrink-0 text-edr-muted" />
<Text c="dimmed" size="sm" className="truncate">
{assignment.customerName ?? "—"}
</Text>
</Group>
{origin || destination ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin ?? "Origin"}
</Text>
<ArrowRight size={13} className="shrink-0 text-edr-muted" />
<Text size="sm" c="dimmed" fw={600}>
{destination ?? "Destination"}
</Text>
</Group>
) : null}
</Group>
</div>
</Group>
</Group>
</Stack>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. You can see
this booking, but reviewing its documents, uploading, and assigning
a Djibouti transit agent unlock once it is approved.
</Alert>
) : null}
{/* ── KPI strip ───────────────────────────────────────────── */}
{assignedBookingsUnlocked ? (
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
{kpis.map((k) => (
<Card key={k.label} withBorder radius="lg" p="md" shadow="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={k.color} radius="md" size={36}>
<k.icon size={18} />
</ThemeIcon>
<Box>
<Text fz={11} fw={600} tt="uppercase" c="edr-muted" style={{ letterSpacing: "0.06em" }}>
{k.label}
</Text>
<Text fz={22} fw={800} lh={1.1} c="edr-text">
{k.value}
</Text>
</Box>
</Group>
</Card>
))}
</SimpleGrid>
) : null}
<Tabs defaultValue="clearance" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="clearance" leftSection={<ClipboardList size={14} />}>
Clearance
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="clearance">
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="md">
<div className="flex size-9 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<FileText size={18} />
</div>
<Box>
<Title order={4}>Document review</Title>
<Text c="edr-muted" size="sm">
The customer's paperwork, reviewed by you as the
clearing agent.
</Text>
</Box>
</Group>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
The document list opens once your role is approved.
</Alert>
) : bookingQuery.isPending ? (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the booking…
</Text>
</Group>
) : bookingQuery.isError || !booking ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
The booking could not be loaded.
</Alert>
) : (
<ForwarderDocumentReview booking={booking} onChanged={refresh} />
)}
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{assignedBookingsUnlocked && bookingId ? (
<AdditionalDocsRequestCard
bookingId={bookingId}
requests={clearance?.docRequests ?? []}
canRequest={clearance?.documentsOpen !== false}
onSent={refresh}
/>
) : null}
<DjiboutiAgentCard assignment={assignment} />
<BookingFactsCard assignment={assignment} booking={booking ?? null} />
{assignedBookingsUnlocked && clearance ? (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<PackageCheck size={16} />
</ThemeIcon>
<Title order={5}>Review progress</Title>
</Group>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
</Stack>
</Card>
) : null}
</Stack>
</Grid.Col>
</Grid>
</Tabs.Panel>
<Tabs.Panel value="history">
{assignedBookingsUnlocked && bookingId ? (
<HistoryPanel bookingId={bookingId} />
) : (
<Alert color="yellow" variant="light" radius="md">
The clearance history opens once your role is approved.
</Alert>
)}
</Tabs.Panel>
</Tabs>
</Stack>
</Box>
);
@@ -227,3 +485,361 @@ function Fact({
</Box>
);
}
/** Who the booking belongs to and where the assignment stands — the side card. */
function BookingFactsCard({
assignment,
booking,
}: {
assignment: TransitAssignment;
booking: Freight.IBooking | null;
}) {
const contractRef = (booking as { contract?: { reference?: string | null } } | null)
?.contract?.reference;
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="gray" radius="md" size={32}>
<Building2 size={16} />
</ThemeIcon>
<Title order={5}>Customer & booking</Title>
</Group>
<Stack gap="sm">
<Fact
icon={<Building2 size={14} />}
label="Customer"
value={assignment.customerName ?? "—"}
/>
{contractRef ? (
<Fact icon={<FileText size={14} />} label="Contract" value={contractRef} />
) : null}
<Group gap="xl" wrap="wrap">
<Fact icon={<Clock3 size={14} />} label="Assigned" value={formatDate(assignment.assignedAt)} />
<Fact icon={<Clock3 size={14} />} label="Started" value={formatDate(assignment.startedAt)} />
<Fact icon={<Clock3 size={14} />} label="Finished" value={formatDate(assignment.finishedAt)} />
</Group>
{assignment.note ? (
<Text fz={13} c="edr-text" style={{ whiteSpace: "pre-wrap" }}>
{assignment.note}
</Text>
) : null}
</Stack>
</Card>
);
}
/**
* Ask the customer for additional document(s) in plain words — the same card
* the GL desk has. The note, its author and its time show on the customer's
* booking page beside the upload box.
*/
function AdditionalDocsRequestCard({
bookingId,
requests,
canRequest,
onSent,
}: {
bookingId: string;
requests: Freight.ClearanceDocRequest[];
canRequest: boolean;
onSent?: () => void;
}) {
const [note, setNote] = useState("");
const send = useMutation({
mutationFn: () =>
transitAssignmentsService.requestAdditionalDocuments(bookingId, note.trim()),
onSuccess: () => {
toast.success("Request sent to the customer");
setNote("");
onSent?.();
},
onError: (e: Error) => toast.error(apiMessage(e, "Could not send the request")),
});
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="red" radius="md" size={32}>
<FilePlus2 size={16} />
</ThemeIcon>
<Box>
<Title order={5}>Request more documents</Title>
<Text c="edr-muted" size="xs">
The customer sees your note next to their upload box.
</Text>
</Box>
</Group>
{canRequest ? (
<Stack gap="xs">
<Textarea
autosize
minRows={2}
radius="md"
placeholder="e.g. Please upload the signed packing list and the insurance certificate."
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
size="compact-sm"
color="red"
radius="md"
leftSection={<MessageSquare size={14} />}
disabled={!note.trim()}
loading={send.isPending}
onClick={() => send.mutate()}
>
Send request
</Button>
</Group>
</Stack>
) : (
<Text fz={13} c="dimmed">
Documents are closed for this booking.
</Text>
)}
{requests.length > 0 ? (
<Stack gap={6} mt="sm">
{requests.slice(0, 3).map((r) => (
<Text key={r.id} fz={12} c="dimmed">
<Text span c="edr-text">
{r.note}
</Text>{" "}
· {r.byName ?? "—"} · {formatDateTime(r.at)}
</Text>
))}
</Stack>
) : null}
</Card>
);
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
function eventMeta(action: string): { icon: typeof CircleDot; color: string } {
if (action.includes("APPROVED") || action.includes("FINALIZ")) {
return { icon: CheckCircle2, color: "edr-green" };
}
if (action.includes("QUERIED") || action.includes("REQUEST")) {
return { icon: MessageSquare, color: "red" };
}
if (action.includes("ASSIGN")) return { icon: UserCheck, color: "blue" };
if (action.includes("DOC") || action.includes("UPLOAD")) {
return { icon: FileText, color: "gray" };
}
return { icon: CircleDot, color: "gray" };
}
/** The clearance trail — reviews, requests, assignments — newest first. */
function HistoryPanel({ bookingId }: { bookingId: string }) {
const historyQuery = useQuery({
queryKey: ["forwarder-clearance-history", bookingId],
queryFn: () => transitAssignmentsService.clearanceHistory(bookingId),
});
const events = historyQuery.data ?? [];
if (historyQuery.isPending) {
return (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (historyQuery.isError) {
return (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
History is not available for this shipment.
</Alert>
);
}
if (events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Approvals, queries, document
requests and assignments appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note = typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}
/**
* The Djibouti transit officer on this booking, named by the forwarder.
*
* The forwarder clears customs on the Ethiopian side; the transit leg through
* Djibouti is a separate officer from the Djibouti roster, and on a
* without-customs booking nobody at GL Djibouti is in the loop to name one —
* so the forwarder does it here. Picking again reassigns; the officer is
* told either way. Locked until the roster role is approved, like uploads.
*/
function DjiboutiAgentCard({ assignment }: { assignment: TransitAssignment }) {
const { assignedBookingsUnlocked } = useAuth();
const queryClient = useQueryClient();
const [picked, setPicked] = useState<string | null>(null);
const bookingQuery = useQuery({
...api.bookings.get.queryOptions({ input: { id: assignment.bookingId } }),
enabled: assignedBookingsUnlocked,
});
// The API spreads the booking entity into its response, so the officer's
// name is there even though the shared type does not declare it.
const currentName =
(bookingQuery.data as { transitAssigneeName?: string | null } | undefined)
?.transitAssigneeName ?? null;
const rosterQuery = useQuery({
queryKey: ["transit-agents", "djibouti-options"],
queryFn: transitAssignmentsService.djiboutiAgents,
enabled: assignedBookingsUnlocked,
staleTime: 5 * 60 * 1000,
});
const options = (rosterQuery.data ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const mutation = useMutation({
mutationFn: (transitAgentId: string) =>
transitAssignmentsService.assignDjiboutiAgent(
assignment.bookingId,
transitAgentId,
),
onSuccess: () => {
toast.success("Djibouti transit agent assigned and notified.");
setPicked(null);
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: assignment.bookingId }),
});
},
onError: (e: Error) => {
const data = (
e as { response?: { data?: { message?: string | string[] } } }
).response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
toast.error(message || e.message || "Could not assign the transit agent");
},
});
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="xs">
<div className="flex size-9 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
<ShipWheel size={18} />
</div>
<Box>
<Title order={4}>Djibouti transit agent</Title>
<Text c="edr-muted" size="sm">
Hand the Djibouti transit leg of this shipment to an officer from
the Djibouti roster. They are notified when you assign them.
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap" mb="md">
<UserCheck size={14} className="shrink-0 text-edr-muted" />
<Text fz={13} c="edr-text">
{currentName ? (
<>
Currently assigned: <Text span fw={600}>{currentName}</Text>
</>
) : (
"No Djibouti transit agent assigned yet."
)}
</Text>
</Group>
{!assignedBookingsUnlocked ? (
<Alert color="yellow" variant="light" radius="md">
Your transit agent registration is still under review. Assigning a
Djibouti transit agent unlocks once it is approved.
</Alert>
) : (
<Group align="flex-end" gap="sm" wrap="wrap">
<Select
label={currentName ? "Change to" : "Transit agent"}
placeholder={
rosterQuery.isLoading
? "Loading the Djibouti roster…"
: options.length === 0
? "No Djibouti transit agents are listed yet"
: "Type to search by name"
}
data={options}
value={picked}
onChange={setPicked}
disabled={rosterQuery.isLoading || options.length === 0}
searchable
clearable
nothingFoundMessage="No transit agent matches that name"
radius="md"
w={{ base: "100%", sm: 320 }}
comboboxProps={{ withinPortal: true }}
/>
<Button
color="edr-green"
radius="md"
leftSection={<UserCheck size={16} />}
loading={mutation.isPending}
disabled={!picked}
onClick={() => picked && mutation.mutate(picked)}
>
{currentName ? "Reassign" : "Assign"}
</Button>
</Group>
)}
{rosterQuery.isError ? (
<Alert color="red" radius="md" mt="sm" icon={<AlertCircle size={16} />}>
The Djibouti roster could not be loaded.
</Alert>
) : null}
</Card>
);
}

View File

@@ -0,0 +1,460 @@
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileText,
MessageSquare,
ShieldCheck,
Upload,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { ClearanceAdHocUploadSection } from "@/components/contracts/ClearanceAdHocUploadSection";
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { api } from "@/services/api";
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
const BORDER = "#E6ECF2";
function apiMessage(e: Error, fallback: string): string {
const data = (e as { response?: { data?: { message?: string | string[] } } })
.response?.data;
const message = Array.isArray(data?.message)
? data.message.join(", ")
: data?.message;
return message || e.message || fallback;
}
/**
* The clearing agent's review of a booking's import/export documents — the
* same job the GL Ethiopia desk does on a customs booking, on the portal.
*
* Each customer document shows its file, review state and any query note.
* The forwarder can approve it, query it with a note (the customer is told
* and re-uploads), upload a missing or queried document on the customer's
* behalf, and — once every required document is approved — finalize, which
* moves the booking to CLEARANCE_READY so the customer can complete it.
*
* Uploads ride the customer's own flow controller so the staged files, the
* ad-hoc rows and the submit rules are exactly what the API accepts.
*/
export function ForwarderDocumentReview({
booking,
onChanged,
}: {
booking: Freight.IBooking;
onChanged?: () => void;
}) {
const queryClient = useQueryClient();
const flow = useClearanceFlow(booking);
const { view, viewer } = useFileViewer();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const refresh = () => {
void queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
void queryClient.invalidateQueries({
queryKey: ["forwarder-clearance-history", booking.id],
});
onChanged?.();
};
const review = useMutation({
mutationFn: (input: {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) => transitAssignmentsService.reviewDocument(booking.id, input),
onSuccess: (_b, input) => {
toast.success(
input.status === "APPROVED"
? "Document approved"
: "Query sent to the customer",
);
setOpenQuery((o) => ({ ...o, [input.fileKey]: false }));
setQueryNotes((n) => ({ ...n, [input.fileKey]: "" }));
refresh();
},
onError: (e: Error) =>
toast.error(apiMessage(e, "Could not record the review")),
});
const finalize = useMutation({
mutationFn: () => transitAssignmentsService.finalizeClearance(booking.id),
onSuccess: () => {
toast.success(
"Clearance finalized — the customer can now complete the booking.",
);
refresh();
},
onError: (e: Error) =>
toast.error(apiMessage(e, "Could not finalize clearance")),
});
if (flow.isLoading || !flow.clearance) {
return (
<Group gap="sm">
<Loader size="sm" color="edr-green" />
<Text c="edr-muted" size="sm">
Loading the document list
</Text>
</Group>
);
}
const { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload;
const canFinalize =
status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [
"CLEARANCE_READY",
"OPERATION_REQUEST_PENDING",
"OPERATION_REQUESTED",
"OPERATION_CHANGES_REQUESTED",
].includes(status);
return (
<Stack gap="md">
{finalized ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
Clearance is finalized. The customer completes the booking from
here; documents stay open for additions until the shipment is paid.
</Alert>
) : status === "AWAITING_DOCUMENTS" ? (
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
Waiting for the {docNoun}. Upload them on the customer's behalf
below, or wait for the customer to upload; review starts once the
required set is in.
</Alert>
) : (
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
Review each document: approve it, or query it with a note the
customer sees. Finalize once every required document is approved.
</Alert>
)}
<Box>
<Text fz={13} fw={700} c="#10202F">
The customer's {docNoun}
</Text>
<Text fz={12} c="dimmed" mt={4}>
Items marked * are required before clearance can be finalized.
</Text>
</Box>
<Stack gap="sm">
{customerDocs.map((doc) => {
const hasFile = Boolean(doc.file);
const reviewable =
reviewOpen && hasFile && doc.reviewStatus !== "APPROVED";
const queryOpen = openQuery[doc.fileKey] ?? false;
return (
<Box key={doc.fileKey}>
<ClearanceDocumentUploadCard
label={doc.label}
required={doc.required}
reviewStatus={doc.reviewStatus}
note={doc.note}
uploadedFile={doc.file}
stagedFile={pending[doc.fileKey] ?? null}
canUpload={canUpload}
onStageFile={
canUpload && doc.reviewStatus !== "APPROVED"
? (file) => flow.stagePending(doc.fileKey, file)
: undefined
}
onPreview={view}
/>
{reviewable ? (
<Box
mt={-6}
px="md"
py={10}
style={{
border: `1px solid ${BORDER}`,
borderTop: 0,
borderRadius: "0 0 12px 12px",
background: "#FAFBFC",
}}
>
<Group gap="sm" wrap="wrap" align="center">
<Text fz={12} c="dimmed">
{doc.reviewStatus === "QUERIED"
? "Queried — awaiting the customer's re-upload."
: "Your decision:"}
</Text>
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={
review.isPending &&
review.variables?.fileKey === doc.fileKey &&
review.variables?.status === "APPROVED"
}
onClick={() =>
review.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
}
>
Approve
</Button>
{doc.reviewStatus !== "QUERIED" ? (
<Button
size="compact-sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquare size={14} />}
onClick={() =>
setOpenQuery((o) => ({
...o,
[doc.fileKey]: !queryOpen,
}))
}
>
Query
</Button>
) : null}
</Group>
{queryOpen ? (
<Stack gap={6} mt="sm">
<Textarea
autosize
minRows={2}
radius="md"
placeholder="What is wrong with this document, and what should the customer send instead?"
value={queryNotes[doc.fileKey] ?? ""}
onChange={(e) =>
setQueryNotes((n) => ({
...n,
[doc.fileKey]: e.currentTarget.value,
}))
}
/>
<Group justify="flex-end" gap="xs">
<Button
size="compact-sm"
variant="default"
radius="md"
onClick={() =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: false }))
}
>
Cancel
</Button>
<Button
size="compact-sm"
color="red"
radius="md"
disabled={!(queryNotes[doc.fileKey] ?? "").trim()}
loading={
review.isPending &&
review.variables?.fileKey === doc.fileKey &&
review.variables?.status === "QUERIED"
}
onClick={() =>
review.mutate({
fileKey: doc.fileKey,
status: "QUERIED",
note: (queryNotes[doc.fileKey] ?? "").trim(),
})
}
>
Send query
</Button>
</Group>
</Stack>
) : null}
</Box>
) : null}
</Box>
);
})}
</Stack>
{flow.workflowFiles.length > 0 ? (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="sm">
Clearance documents from Global Logistics
</Text>
<Stack gap={8}>
{flow.workflowFiles.map((doc) => (
<Group
key={doc.code}
justify="space-between"
wrap="nowrap"
style={{
border: `1px solid ${BORDER}`,
borderRadius: 12,
padding: 10,
}}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
{isViewable({ name: doc.file.name, url: "" }) ? (
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(
view,
)
}
>
<Eye size={15} />
</Button>
) : null}
<Button
variant="subtle"
color="gray"
size="compact-sm"
onClick={() =>
void downloadStoredFile(doc.file.id, doc.file.name)
}
>
<Download size={15} />
</Button>
</Group>
</Group>
))}
</Stack>
</>
) : null}
{(clearance.docRequests?.length ?? 0) > 0 ? (
<Box>
<Text fz="12.5px" fw={700} c="#C0392B" mb={8}>
Documents requested from the customer
</Text>
<Stack gap={8}>
{clearance.docRequests!.map((r) => (
<Alert
key={r.id}
color="red"
variant="light"
radius="md"
icon={<MessageSquare size={16} />}
p="xs"
>
<Text fz="12.5px" c="#10202F">
{r.note}
</Text>
<Text fz="11px" c="dimmed" mt={4}>
{r.byName ?? "Clearing agent"} ·{" "}
{new Date(r.at).toLocaleString()}
</Text>
</Alert>
))}
</Stack>
</Box>
) : null}
{canUpload ? (
<ClearanceAdHocUploadSection
rows={adHoc}
onAdd={flow.addAdHocRow}
onRemove={flow.removeAdHocRow}
onNameChange={flow.setAdHocName}
onFileChange={flow.setAdHocFile}
onPreview={view}
/>
) : null}
{flow.uploadMutation.isError ? (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{flow.uploadMutation.error instanceof Error
? flow.uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
) : null}
<Group justify="space-between" gap="sm" wrap="wrap" mt="sm">
<Group gap={6}>
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={12} />}
>
All required documents approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={12} />}
>
Review in progress
</Badge>
)}
</Group>
<Group gap="sm">
{canUpload ? (
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.canSubmit}
>
Submit documents
</Button>
) : null}
{!finalized ? (
<Button
color="edr-green"
radius="md"
leftSection={<ShieldCheck size={16} />}
onClick={() => finalize.mutate()}
loading={finalize.isPending}
disabled={!canFinalize}
>
Finalize clearance
</Button>
) : null}
</Group>
</Group>
{viewer}
</Stack>
);
}

View File

@@ -44,6 +44,21 @@ function isMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
const formatStamp = (value: string): string => new Date(value).toLocaleString();
/**
* The rail leg's own stamps, so a train that has left reads as in transit
* rather than untouched — the step only completes on arrival.
*/
function trainLegDescription(
train: Freight.ClearanceView["train"] | undefined,
): string {
if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`;
if (train?.departedAt)
return `Departed ${formatStamp(train.departedAt)} · in transit`;
return "Departure and arrival";
}
/**
* The clearance stepper a transit agent sees on a shipment assigned to them,
* laid out as the backoffice's clearance action panel is, plus the RO
@@ -203,7 +218,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Train to Djibouti",
description: "Departure and arrival",
description: trainLegDescription(clearance?.train),
done: Boolean(clearance?.train?.arrivedAt),
},
{

View File

@@ -1,5 +1,6 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "@/utils/api";
const BASE = "/api/transit-assignments/my";
@@ -365,6 +366,65 @@ export const transitAssignmentsService = {
);
},
// ── Djibouti officer, named by the assigned clearing agent ───────────────
// A without-customs booking has no GL Djibouti desk in the loop, so the
// forwarder the customer assigned hands the transit leg to a Djibouti
// officer itself. The API accepts this only from the agent assigned to the
// booking, and only for an active Djiboutian roster entry.
/** The Djibouti roster, id + name only. */
djiboutiAgents: async (): Promise<{ id: string; name: string }[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRANSIT_AGENTS_API.DJIBOUTI_OPTIONS,
);
return data.data ?? data;
},
/** Name (or change) the Djibouti transit officer on an assigned booking. */
assignDjiboutiAgent: async (
bookingId: string,
transitAgentId: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/transit-assignee/assign`,
{ transitAgentId },
);
return data.data ?? data;
},
// ── Clearing-agent review, on a without-customs booking ──────────────────
// The forwarder the customer assigned clears customs in GL's place, so it
// works the same review the GL desk does: approve or query each document,
// ask the customer for more, and finalize once everything is approved. The
// API accepts these only from the agent assigned to the booking.
reviewDocument: async (
bookingId: string,
input: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/review`,
input,
);
return data.data ?? data;
},
requestAdditionalDocuments: async (
bookingId: string,
note: string,
): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/doc-requests`, {
note,
});
},
finalizeClearance: async (bookingId: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/finalize`,
);
return data.data ?? data;
},
/** T1 transit documents (import); locked once GL Ethiopia closes the T1. */
uploadT1Documents: async (
bookingId: string,