mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 08:58:21 +00:00
changes on transit agent
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user