add GL operations for customs risk assignment, duty advising, and incident reporting

This commit is contained in:
Marshal
2026-06-28 16:09:45 +00:00
parent 7c744352d1
commit e1d54746c2
31 changed files with 1879 additions and 29 deletions

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Global Logistics Phase-2 operational features (docs/new-doc.md §11§13, gap
* matrix #14/#16/#17/#18):
* - `clearance_milestones.metadata` — structured payload for RISK_ASSIGNED
* (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
* - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
* — station routing + staff binding (GL US-02)
* - `freight.clearance_incidents` — cargo exception/damage reports with photos
* (GL Import US-07)
*/
export class AddGlOperations1825000000000 implements MigrationInterface {
name = 'AddGlOperations1825000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
incident_type VARCHAR(32) NOT NULL,
description TEXT NOT NULL,
photo_file_ids JSONB NOT NULL DEFAULT '[]',
reported_by_user_id UUID,
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
);
}
}

View File

@@ -426,6 +426,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
// ── Global Logistics station routing (GL Import/Export US-02) ──────────────
/** Origin-station yard the shipment is routed to for GL handling. */
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
glStationYardId?: string | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;
@Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true })
glAssignedAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -1,7 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -104,6 +108,85 @@ export class ClearanceMilestoneService {
return saved;
}
/**
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
* milestone metadata so the timeline shows it.
*/
async assignRisk(
bookingId: string,
riskLevel: CustomsRiskLevel,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/**
* Advise duty & tax (amount + declaration serial) and complete the
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
* payment slip, which doc-triggers DUTY_TAX_PAID.
*/
async adviseDuty(
bookingId: string,
input: { amount: number; currency: string; declarationSerial?: string },
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadata(
bookingId,
'DUTY_TAXES_ADVISED',
{
dutyAmount: input.amount,
dutyCurrency: input.currency,
declarationSerial: input.declarationSerial,
},
userId,
note,
);
}
/** Complete a milestone and merge structured metadata onto it. */
private async completeWithMetadata(
bookingId: string,
code: string,
metadata: MilestoneMetadata,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
if (note) milestone.note = note;
return this.repo.save(milestone);
}
/** Mark a pre-booking milestone complete (by code) on a contract cycle. */
async completeForContract(
contractId: string,
code: string,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
}
if (milestone.status === 'COMPLETED') {
throw new BadRequestException(`Milestone ${code} is already completed.`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
if (note) milestone.note = note;
return this.repo.save(milestone);
}
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
async completeByDocTrigger(
scope: { bookingId?: string; contractId?: string },

View File

@@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { SignaturesService } from '../signatures/signatures.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
@@ -57,6 +58,13 @@ import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
AdviseDutyDto,
AssignRiskDto,
AssignStationDto,
CompleteMilestoneDto,
ReportIncidentDto,
} from './dto/gl-operations.dto';
@ApiTags('contracts')
@Controller('contracts')
@@ -69,6 +77,7 @@ export class ContractsController {
private readonly clearanceService: ContractClearanceService,
private readonly contractBookingService: ContractBookingService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly glOperationsService: GlOperationsService,
private readonly signaturesService: SignaturesService,
) {}
@@ -506,4 +515,121 @@ export class ContractsController {
body?.note,
);
}
@Post(':id/milestones/:code/complete')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' })
completeContractMilestone(
@Param('id', ParseUUIDPipe) id: string,
@Param('code') code: string,
@Body() body: CompleteMilestoneDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.completeForContract(
id,
code,
resolveAuthUserId(user),
body?.note,
);
}
// ── GL operational actions on a booking (doc §11§13) ──────────────────────
@Post('bookings/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/duty')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' })
adviseDuty(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AdviseDutyDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.adviseDuty(
bookingId,
{ amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial },
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/station-assign')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' })
assignStation(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignStationDto,
) {
return this.glOperationsService.assignStation(bookingId, {
stationYardId: dto.stationYardId,
staffId: dto.staffId,
});
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
})
uploadGlDocuments(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
}
@Post('bookings/:bookingId/duty-slip')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
uploadDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
}
@Get('bookings/:bookingId/incidents')
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId);
}
@Post('bookings/:bookingId/incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' })
reportIncident(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ReportIncidentDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.reportIncident(
bookingId,
{
incidentType: dto.incidentType,
description: dto.description,
files: files ?? [],
},
resolveAuthUserId(user),
);
}
}

View File

@@ -19,6 +19,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
@@ -30,6 +31,8 @@ import { ContractReviewNote } from './entities/contract-review-note.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ContractDocumentReview } from './entities/contract-document-review.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ClearanceIncident } from './entities/clearance-incident.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
@@ -50,6 +53,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractClearanceCycle,
ContractDocumentReview,
ClearanceMilestone,
ClearanceIncident,
Booking,
BookingContainerUnit,
]),
RuleEngineModule,
@@ -76,6 +81,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
// Contract PDF providers (template resolution + render + PDF) — stateless
// helpers reused from src/contracts/.
ContractTemplateResolver,

View File

@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
import { CUSTOMS_RISK_LEVELS } from '../entities/clearance-milestone.entity';
import { INCIDENT_TYPES } from '../entities/clearance-incident.entity';
export class AssignRiskDto {
@ApiProperty({ enum: CUSTOMS_RISK_LEVELS })
@IsIn(CUSTOMS_RISK_LEVELS as unknown as string[])
riskLevel!: (typeof CUSTOMS_RISK_LEVELS)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AdviseDutyDto {
@ApiProperty({ description: 'Duty & tax amount advised to the customer' })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@MinLength(1)
currency!: string;
@ApiPropertyOptional({ description: 'Customs declaration serial number' })
@IsOptional()
@IsString()
declarationSerial?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AssignStationDto {
@ApiProperty({ description: 'Origin-station yard the shipment is routed to' })
@IsUUID()
stationYardId!: string;
@ApiPropertyOptional({ description: 'GL staff user bound to this shipment' })
@IsOptional()
@IsUUID()
staffId?: string;
}
export class ReportIncidentDto {
@ApiProperty({ enum: INCIDENT_TYPES })
@IsIn(INCIDENT_TYPES as unknown as string[])
incidentType!: (typeof INCIDENT_TYPES)[number];
@ApiProperty({ description: 'Mandatory free-text narrative of the anomaly' })
@IsString()
@MinLength(1)
description!: string;
}
export class CompleteMilestoneDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
/**
* Standard anomaly taxonomy for cargo exception reporting at a border/port
* station (GL Import US-07 AC1.2). GL Djibouti logs one of these against a
* shipment with a free-text narrative and photo evidence; a high-priority alert
* is then surfaced to GL Ethiopia.
*/
export const INCIDENT_TYPES = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];
/**
* A cargo exception/damage report raised during loading or handover. Attaches to
* a booking (post-booking phase) and carries one or more photo file references
* for verification. See docs/new-doc.md §14 gap #18, GL Import US-07.
*/
@Entity({ schema: 'freight', name: 'clearance_incidents' })
@Index(['bookingId'])
export class ClearanceIncident extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'incident_type', type: 'varchar', length: 32 })
incidentType!: IncidentType;
@Column({ name: 'description', type: 'text' })
description!: string;
/** MinIO file ids of the uploaded photo evidence (.jpg). */
@Column({ name: 'photo_file_ids', type: 'jsonb', default: () => "'[]'" })
photoFileIds!: string[];
@Column({ name: 'reported_by_user_id', type: 'uuid', nullable: true })
reportedByUserId?: string | null;
@Column({ name: 'reported_at', type: 'timestamptz', default: () => 'NOW()' })
reportedAt!: Date;
}

View File

@@ -9,6 +9,22 @@ export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* Structured payload some milestones carry beyond a plain note (doc §11.3):
* - RISK_ASSIGNED → `riskLevel`
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline.
*/
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
}
/**
* A GL clearance milestone (1823 per direction). Pre-booking milestones attach
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
@@ -60,6 +76,9 @@ export class ClearanceMilestone extends BaseEntity {
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: MilestoneMetadata | null;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -0,0 +1,161 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
* uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
* marks the milestone done so the timeline advances without a separate click.
*/
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
t1_transport_document: 'T1_CLOSED', // import — GL ET
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
};
/**
* Operational Global Logistics actions that hang off a shipment booking after GL
* creates it: station routing, damage/incident reporting, and the phased GL
* document uploads (Release Order, Delivery Order, T1, etc.) that advance
* doc-triggered milestones. See docs/new-doc.md §11§13, gap matrix #14/#16/#18.
*/
@Injectable()
export class GlOperationsService {
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
) {}
private get bookings() {
return this.dataSource.getRepository(Booking);
}
private get incidents() {
return this.dataSource.getRepository(ClearanceIncident);
}
private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
/**
* Route a shipment to an origin station and (optionally) bind a GL staff user
* to it (GL US-02). Setting both moves the shipment to that station's queue.
*/
async assignStation(
bookingId: string,
input: { stationYardId: string; staffId?: string },
): Promise<Booking> {
const booking = await this.getBooking(bookingId);
booking.glStationYardId = input.stationYardId;
if (input.staffId) {
booking.glAssignedStaffId = input.staffId;
booking.glAssignedAt = new Date();
}
return this.bookings.save(booking);
}
/** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
async reportIncident(
bookingId: string,
input: {
incidentType: IncidentType;
description: string;
files: Express.Multer.File[];
},
userId?: string,
): Promise<ClearanceIncident> {
await this.getBooking(bookingId);
if (!input.description?.trim()) {
throw new BadRequestException('A description is required for an incident report.');
}
const photoFileIds: string[] = [];
for (const file of input.files ?? []) {
const record = await this.filesService.upload({
resourceId: bookingId,
resource: 'bookings',
code: 'incident_photo',
file,
});
photoFileIds.push(record.id);
}
const incident = this.incidents.create({
bookingId,
incidentType: input.incidentType,
description: input.description.trim(),
photoFileIds,
reportedByUserId: userId ?? null,
reportedAt: new Date(),
});
return this.incidents.save(incident);
}
async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
return this.incidents.find({
where: { bookingId },
order: { reportedAt: 'DESC' },
});
}
/**
* Customer uploads the duty/tax payment slip after GL advised the amount. The
* slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
*/
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
): Promise<{ milestoneCompleted: boolean }> {
await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt',
file,
});
await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
return { milestoneCompleted: true };
}
/**
* GL uploads a post-booking operational document (DO, RO, T1, import release,
* interchange…). The file attaches to the booking; if the code maps to a
* doc-triggered milestone, that milestone auto-completes.
*/
async uploadDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: number; completedMilestones: string[] }> {
await this.getBooking(bookingId);
if (!files?.length) throw new BadRequestException('No documents uploaded');
const completedMilestones: string[] = [];
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: file.fieldname,
file,
});
const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
if (milestoneCode) {
await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
completedMilestones.push(milestoneCode);
}
}
return { uploaded: files.length, completedMilestones };
}
}

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Badge, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Check, type LucideIcon } from "lucide-react";
export interface ActionShellProps {
icon: LucideIcon;
title: string;
subtitle?: string;
/** When true the action is already done — children are hidden, a done badge shows. */
done?: boolean;
doneLabel?: ReactNode;
children: ReactNode;
}
/**
* Consistent container for one GL action card: icon, title, and either the
* input controls (pending) or a completed badge (done). Keeps every GL action
* visually uniform inside {@link GlActionsPanel}.
*/
export function ActionShell({
icon: Icon,
title,
subtitle,
done,
doneLabel,
children,
}: ActionShellProps) {
return (
<Box
p="md"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: 12,
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Icon size={16} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
) : null}
</Stack>
</Group>
{done ? (
typeof doneLabel === "string" || !doneLabel ? (
<Badge
color="edr-green"
variant="light"
radius="sm"
leftSection={<Check size={12} />}
>
{doneLabel ?? "Done"}
</Badge>
) : (
doneLabel
)
) : null}
</Group>
{!done ? children : null}
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import {
Button,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Receipt } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAdviseDuty } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
export function AdviseDutyCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const advise = useAdviseDuty(bookingId);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const done = milestone.status === "COMPLETED";
const meta = milestone.metadata;
return (
<ActionShell
icon={Receipt}
title="Duty & tax"
subtitle="Advise the duty/tax amount and declaration serial."
done={done}
doneLabel={
meta?.dutyAmount != null
? `${meta.dutyAmount.toLocaleString()} ${meta.dutyCurrency ?? ""}`
: "Advised"
}
>
<Stack gap="sm">
<Group grow>
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
size="sm"
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
allowDeselect={false}
/>
</Group>
<TextInput
label="Declaration serial"
placeholder="e.g. IM4-2026-00123"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Customer uploads the payment slip after being advised.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={advise.isPending}
disabled={!amount || Number(amount) <= 0}
onClick={() =>
advise.mutate({
amount: Number(amount),
currency,
declarationSerial: serial.trim() || undefined,
})
}
>
Advise customer
</Button>
</Group>
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
import { ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAssignRisk } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
export function AssignRiskCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const assign = useAssignRisk(bookingId);
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
const assigned = milestone.status === "COMPLETED";
const current = milestone.metadata?.riskLevel;
return (
<ActionShell
icon={ShieldAlert}
title="Customs risk"
subtitle="Assign the customs examination risk level."
done={assigned}
doneLabel={
current ? (
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
{current}
</Badge>
) : (
"Assigned"
)
}
>
<Box>
<SegmentedControl
fullWidth
value={level}
onChange={(v) => setLevel(v as Freight.CustomsRiskLevel)}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between" mt="sm">
<Text size="xs" c="dimmed">
Customer is notified of the assigned risk.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
onClick={() => assign.mutate({ riskLevel: level })}
>
Assign risk
</Button>
</Group>
</Box>
</ActionShell>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Select, Text } from "@mantine/core";
import { MapPin } from "lucide-react";
import { api } from "@/services/api";
import { useAssignStation } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
/**
* Routes the shipment to an origin station (GL US-02). Binding a staff user is
* optional here — the station manager can assign one later.
*/
export function AssignStationCard({ bookingId }: { bookingId: string }) {
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const assign = useAssignStation(bookingId);
const [stationYardId, setStationYardId] = useState<string | null>(null);
return (
<ActionShell
icon={MapPin}
title="Station routing"
subtitle="Route this shipment to the handling station."
>
<Group align="flex-end" wrap="nowrap" gap="sm">
<Select
flex={1}
label="Station"
placeholder="Select station"
searchable
data={yards.map((y) => ({ value: y.id, label: y.label }))}
value={stationYardId}
onChange={setStationYardId}
size="sm"
/>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
disabled={!stationYardId}
onClick={() =>
stationYardId && assign.mutate({ stationYardId })
}
>
Route
</Button>
</Group>
<Text size="xs" c="dimmed" mt={6}>
The shipment moves to the selected station's queue.
</Text>
</ActionShell>
);
}

View File

@@ -0,0 +1,67 @@
import { useMemo } from "react";
import { Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { Flag } from "lucide-react";
import { AssignStationCard } from "./AssignStationCard";
import { AssignRiskCard } from "./AssignRiskCard";
import { AdviseDutyCard } from "./AdviseDutyCard";
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
import { IncidentReportCard } from "./IncidentReportCard";
export interface GlActionsPanelProps {
bookingId: string;
milestones: Freight.IClearanceMilestone[];
}
/** Find a milestone by code (post-booking milestones live on the booking). */
function findMilestone(
milestones: Freight.IClearanceMilestone[],
code: string,
): Freight.IClearanceMilestone | undefined {
return milestones.find((m) => m.milestoneCode === code);
}
/**
* Global Logistics action surface for a shipment. Each card is gated by whether
* its milestone exists on this shipment (import vs export differ) and renders the
* structured action (risk level, duty advice, document upload, incident report,
* station routing) that the plain "Complete" button can't capture.
*/
export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
const riskMs = useMemo(
() => findMilestone(milestones, "RISK_ASSIGNED"),
[milestones],
);
const dutyMs = useMemo(
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
[milestones],
);
return (
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
<Stack gap="md">
<Text size="xs" c="dimmed">
Structured GL operations for this shipment. Uploading a document
advances its milestone automatically.
</Text>
<AssignStationCard bookingId={bookingId} />
{dutyMs ? (
<AdviseDutyCard bookingId={bookingId} milestone={dutyMs} />
) : null}
<GlDocumentUploadCard bookingId={bookingId} />
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
) : null}
<IncidentReportCard bookingId={bookingId} />
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
import { FileUp, Upload } from "lucide-react";
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
/**
* GL post-booking document slots. The fieldname (value) maps server-side to a
* doc-triggered milestone in gl-operations.service.ts — uploading auto-advances
* the matching milestone.
*/
const GL_DOC_SLOTS = [
{ value: "delivery_order", label: "Delivery Order (DO)" },
{ value: "release_order", label: "Release Order (RO)" },
{ value: "t1_transport_document", label: "T1 Transport Document" },
{ value: "import_release", label: "Import Release" },
{ value: "full_in_interchange", label: "Full-in Interchange" },
{ value: "final_declaration", label: "Final Declaration" },
];
export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
const upload = useUploadGlDocuments(bookingId);
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
const [file, setFile] = useState<File | null>(null);
const submit = () => {
if (!slot || !file) return;
upload.mutate({ [slot]: file });
setFile(null);
};
return (
<ActionShell
icon={FileUp}
title="GL documents"
subtitle="Upload DO, RO, T1, release, interchange — advances milestones."
>
<Stack gap="sm">
<Select
label="Document type"
data={GL_DOC_SLOTS}
value={slot}
onChange={setSlot}
size="sm"
allowDeselect={false}
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose file"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="edr-green"
loading={upload.isPending}
disabled={!file || !slot}
onClick={submit}
>
Upload
</Button>
</Group>
{!file ? (
<Text size="xs" c="dimmed">
PDF or image. The matching milestone completes on upload.
</Text>
) : null}
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import {
Badge,
Button,
FileButton,
Group,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { AlertTriangle, ImagePlus } from "lucide-react";
import type { Freight } from "@edr/types";
import {
useBookingIncidents,
useReportIncident,
} from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
{ value: "SEAL_BROKEN", label: "Seal is broken" },
{ value: "CONTAINER_OPENED", label: "Container opened" },
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
];
const LABEL: Record<Freight.IncidentType, string> = {
SEAL_BROKEN: "Seal broken",
CONTAINER_OPENED: "Container opened",
CONTAINER_DAMAGED: "Container damaged",
FLUID_LEAKING: "Fluid leaking",
};
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
const report = useReportIncident(bookingId);
const { data: incidents } = useBookingIncidents(bookingId);
const [type, setType] = useState<Freight.IncidentType>("SEAL_BROKEN");
const [description, setDescription] = useState("");
const [photos, setPhotos] = useState<File[]>([]);
const submit = () => {
if (!description.trim()) return;
report.mutate(
{ incidentType: type, description: description.trim(), photos },
{
onSuccess: () => {
setDescription("");
setPhotos([]);
},
},
);
};
return (
<ActionShell
icon={AlertTriangle}
title="Cargo exception"
subtitle="Log a damage/anomaly with photo evidence (GL Djibouti)."
>
<Stack gap="sm">
{incidents && incidents.length > 0 ? (
<Stack gap={4}>
{incidents.map((inc) => (
<Group key={inc.id} gap={8} wrap="nowrap">
<Badge color="red" variant="light" radius="sm">
{LABEL[inc.incidentType]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{inc.description}
</Text>
</Group>
))}
</Stack>
) : null}
<Select
label="Incident type"
data={INCIDENT_OPTIONS}
value={type}
onChange={(v) => setType((v as Freight.IncidentType) ?? "SEAL_BROKEN")}
size="sm"
allowDeselect={false}
/>
<Textarea
label="Description"
placeholder="Describe the anomaly…"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
autosize
minRows={2}
size="sm"
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setPhotos} accept="image/jpeg,image/png" multiple>
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<ImagePlus size={14} />}
>
{photos.length > 0 ? `${photos.length} photo(s)` : "Add photos"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="red"
loading={report.isPending}
disabled={!description.trim()}
onClick={submit}
>
Report incident
</Button>
</Group>
</Stack>
</ActionShell>
);
}

View File

@@ -59,6 +59,8 @@ export const QUERY_KEYS = {
milestones: (id: string) => ["contracts", "milestones", id] as const,
bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) =>
["contracts", "booking-incidents", bookingId] as const,
},
BOOKING_ORDERS: {

View File

@@ -155,6 +155,17 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/milestones`,
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
`/contracts/bookings/${bookingId}/milestones/${code}/complete`,
// ── GL post-booking operational actions ──
BOOKING_RISK: (bookingId: string) =>
`/contracts/bookings/${bookingId}/risk`,
BOOKING_DUTY: (bookingId: string) =>
`/contracts/bookings/${bookingId}/duty`,
BOOKING_STATION_ASSIGN: (bookingId: string) =>
`/contracts/bookings/${bookingId}/station-assign`,
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/documents`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},
OTP: {

View File

@@ -297,3 +297,106 @@ export function useCompleteMilestone(bookingId: string) {
onError: () => toast.error("Failed to complete milestone"),
});
}
function invalidateMilestones(qc: QueryClient, bookingId: string) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
}
/** Assign a customs risk level (completes RISK_ASSIGNED). */
export function useAssignRisk(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
riskLevel: Freight.CustomsRiskLevel;
note?: string;
}) => contractsService.assignRisk(bookingId, payload),
onSuccess: () => {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
});
}
/** Advise duty & tax (completes DUTY_TAXES_ADVISED). */
export function useAdviseDuty(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
amount: number;
currency: string;
declarationSerial?: string;
note?: string;
}) => contractsService.adviseDuty(bookingId, payload),
onSuccess: () => {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
});
}
/** Route the shipment to a station + bind GL staff. */
export function useAssignStation(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { stationYardId: string; staffId?: string }) =>
contractsService.assignStation(bookingId, payload),
onSuccess: () => {
toast.success("Shipment routed to station");
void qc.invalidateQueries({
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
});
}
/** Upload GL post-booking documents (DO/RO/T1/…); auto-completes milestones. */
export function useUploadGlDocuments(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (files: Record<string, File | null>) =>
contractsService.uploadGlDocuments(bookingId, files),
onSuccess: (res) => {
const n = res.completedMilestones.length;
toast.success(
n > 0
? `Uploaded — ${n} milestone${n === 1 ? "" : "s"} advanced`
: "Documents uploaded",
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
});
}
/** Incidents for a shipment (damage / exceptions). */
export function useBookingIncidents(bookingId: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId ?? ""),
queryFn: () => contractsService.listIncidents(bookingId!),
enabled: !!bookingId,
});
}
/** Report a cargo exception with photos. */
export function useReportIncident(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
incidentType: Freight.IncidentType;
description: string;
photos: File[];
}) => contractsService.reportIncident(bookingId, payload),
onSuccess: () => {
toast.success("Incident reported");
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
});
}

View File

@@ -18,6 +18,7 @@ import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
import {
useBookingMilestones,
useCompleteMilestone,
@@ -66,6 +67,7 @@ export default function BookingMilestonesPage() {
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<SectionCard icon={Flag} title="Clearance milestones">
{isLoading ? (
<Center py="xl">
@@ -81,6 +83,14 @@ export default function BookingMilestonesPage() {
/>
)}
</SectionCard>
{id ? (
<GlActionsPanel
bookingId={id}
milestones={milestones ?? []}
/>
) : null}
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>

View File

@@ -250,4 +250,75 @@ export const contractsService = {
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
{ note },
),
// ── GL post-booking operational actions ──
assignRisk: (
bookingId: string,
payload: { riskLevel: Freight.CustomsRiskLevel; note?: string },
) =>
postContract<Freight.IClearanceMilestone>(
C.BOOKING_RISK(bookingId),
payload,
),
adviseDuty: (
bookingId: string,
payload: {
amount: number;
currency: string;
declarationSerial?: string;
note?: string;
},
) =>
postContract<Freight.IClearanceMilestone>(
C.BOOKING_DUTY(bookingId),
payload,
),
assignStation: (
bookingId: string,
payload: { stationYardId: string; staffId?: string },
) => postContract(C.BOOKING_STATION_ASSIGN(bookingId), payload),
uploadGlDocuments: async (
bookingId: string,
files: Record<string, File | null>,
): Promise<{ uploaded: number; completedMilestones: string[] }> => {
const form = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) form.append(key, file);
}
const response = await client.post(C.BOOKING_GL_DOCUMENTS(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as {
uploaded: number;
completedMilestones: string[];
};
},
listIncidents: async (
bookingId: string,
): Promise<Freight.IClearanceIncident[]> => {
const response = await client.get(C.BOOKING_INCIDENTS(bookingId));
return (unwrap(response.data) ?? []) as Freight.IClearanceIncident[];
},
reportIncident: async (
bookingId: string,
payload: {
incidentType: Freight.IncidentType;
description: string;
photos: File[];
},
): Promise<Freight.IClearanceIncident> => {
const form = new FormData();
form.append("incidentType", payload.incidentType);
form.append("description", payload.description);
for (const photo of payload.photos) form.append("photos", photo);
const response = await client.post(C.BOOKING_INCIDENTS(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.IClearanceIncident;
},
};

View File

@@ -127,6 +127,8 @@ export const URL_CONSTANTS = {
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/milestones`,
BOOKING_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/duty-slip`,
},
TRAIN_SCHEDULING: {

View File

@@ -58,7 +58,7 @@ export default function MyPortalPage() {
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<HelloSection greeting={greeting} companyName={companyName} />
<ActionNeededSection items={actionItems} />
<ActionNeededSection items={actionItems} contracts={allContracts} />
{serviceOptions.length > 1 && (
<Group justify="flex-end">

View File

@@ -15,15 +15,46 @@ export interface ActionItem {
urgent?: boolean;
}
// Only AWAITING is a pending CUSTOMER action (initial upload or re-upload after a
// query). UNDER_REVIEW is waiting on staff, so it doesn't belong on the card.
const CLEARANCE_ACTION_STATUSES = ["AWAITING_CLEARANCE_DOCUMENTS"];
// Contract statuses that mean clearance is in progress (Path A or B). A queried
// document flips the contract back to AWAITING_CLEARANCE_DOCUMENTS, but the panel
// also allows re-upload while UNDER_REVIEW — so surface both as actionable.
const CLEARANCE_IN_PROGRESS_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
/**
* Whether a contract has a clearance step that needs the customer to upload /
* re-upload documents. Uses contract status AND clearanceStatus so a query is
* caught even if only one field reflects it. Excludes the ready / completed gates.
*/
function contractNeedsClearance(c: Freight.IContract): {
show: boolean;
urgent: boolean;
} {
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
const ready =
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
clearance === "SELF_CLEARED" ||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (ready) return { show: false, urgent: false };
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
clearance === "AWAITING_DOCUMENTS";
const inProgress =
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
clearance === "AWAITING_DOCUMENTS" ||
clearance === "DOCUMENTS_UNDER_REVIEW";
return { show: inProgress, urgent: awaiting };
}
/**
* Derive the list of pending customer actions from the customer's contracts and
* bookings, using status alone (no extra per-record fetch). A contract back in
* AWAITING_CLEARANCE_DOCUMENTS after review means a document was queried and
* needs the customer's attention — flagged urgent.
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
* re-upload after a query) is flagged urgent so the home card shows an upload
* button. See {@link contractNeedsClearance}.
*/
export function deriveActionItems(
contracts: Freight.IContract[],
@@ -42,14 +73,17 @@ export function deriveActionItems(
});
continue;
}
if (CLEARANCE_ACTION_STATUSES.includes(c.status)) {
const clr = contractNeedsClearance(c);
if (clr.show) {
items.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description: "Clearance documents needed",
description: clr.urgent
? "Clearance documents need your action"
: "Clearance under review",
targetId: c.id,
urgent: true,
urgent: clr.urgent,
});
continue;
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueries } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
@@ -10,6 +10,7 @@ import {
PackagePlus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
@@ -18,6 +19,13 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component
import { Card } from "./Card";
import type { ActionItem } from "../actions";
// Contracts whose clearance is still in progress — candidates for a real
// per-document query check (small set; only contracts awaiting/under review).
const CLEARANCE_CANDIDATE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
const KIND_META: Record<
ActionItem["kind"],
{ icon: typeof Upload; label: string; color: string }
@@ -29,19 +37,86 @@ const KIND_META: Record<
};
export interface ActionNeededSectionProps {
/** Non-clearance actions (sign / book / pay) derived from status. */
items: ActionItem[];
/** All of the customer's contracts — used to detect real clearance queries. */
contracts: Freight.IContract[];
}
/**
* Home "needs your attention" card. Lists pending customer actions across
* contracts and bookings. Clearance and payment open in a modal right here;
* sign and book navigate to the relevant page.
* contracts and bookings. Clearance items are derived from the ACTUAL clearance
* documents (so an open query always surfaces an upload button), payment + the
* clearance upload open in a modal right here; sign and book navigate.
*/
export function ActionNeededSection({ items }: ActionNeededSectionProps) {
export function ActionNeededSection({
items: baseItems,
contracts,
}: ActionNeededSectionProps) {
const navigate = useNavigate();
const [clearanceId, setClearanceId] = useState<string | null>(null);
const [payItem, setPayItem] = useState<ActionItem | null>(null);
// Fetch the clearance view for every contract still in a clearance phase, so we
// can detect a queried document precisely (status alone can be ambiguous).
const candidates = useMemo(
() =>
contracts.filter((c) =>
CLEARANCE_CANDIDATE_STATUSES.includes(c.status),
),
[contracts],
);
const clearanceQueries = useQueries({
queries: candidates.map((c) =>
api.contracts.getClearance.queryOptions({ input: { id: c.id } }),
),
});
// Build clearance action items from the fetched views: show whenever the
// customer can still upload (not yet ready for booking), flag urgent + show the
// query count when any document was sent back for correction.
const clearanceItems = useMemo<ActionItem[]>(() => {
const out: ActionItem[] = [];
candidates.forEach((c, i) => {
const view = clearanceQueries[i]?.data;
const docs = view?.documents ?? [];
const customerDocs = docs.filter((d) => d.uploadedBy === "customer");
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const ready =
view?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
view?.clearanceStatus === "SELF_CLEARED" ||
view?.clearanceStatus === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (ready) return;
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
view?.clearanceStatus === "AWAITING_DOCUMENTS";
// Only surface when there's something the customer can do: a query, or the
// contract is awaiting their (re)upload.
if (queried === 0 && !awaiting) return;
out.push({
id: `clearance-${c.id}`,
kind: "clearance",
reference: c.reference,
description:
queried > 0
? `${queried} document${queried > 1 ? "s" : ""} need correction`
: "Clearance documents needed",
targetId: c.id,
urgent: queried > 0 || awaiting,
});
});
return out;
}, [candidates, clearanceQueries]);
// Merge: clearance items (from real docs) + the status-derived sign/book/pay.
const items = useMemo(
() => [...clearanceItems, ...baseItems.filter((i) => i.kind !== "clearance")],
[clearanceItems, baseItems],
).sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
// redirect (clientAction.url); fall back to the public checkout page.
const payMutation = useMutation({

View File

@@ -29,6 +29,7 @@ import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils";
@@ -146,6 +147,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<ContainersCard booking={booking} />
<ShipmentTrackingCard bookingId={booking.id} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">

View File

@@ -0,0 +1,177 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { Check, Circle, Clock, Receipt, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
import { SectionCard, CardTitle } from "./layout";
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Read-only shipment tracking for the customer (Path B). Shows the GL milestone
* progression and, when GL has advised duty/tax but the slip is not yet paid,
* surfaces a payment-slip upload — the only customer action in this phase.
*/
export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const { data: milestones = [] } = useQuery({
queryKey: ["booking-milestones", bookingId],
queryFn: () => contractsService.getBookingMilestones(bookingId),
enabled: !!bookingId,
});
const [slip, setSlip] = useState<File | null>(null);
const uploadSlip = useMutation({
mutationFn: (file: File) => contractsService.uploadDutySlip(bookingId, file),
onSuccess: () => {
setSlip(null);
void qc.invalidateQueries({ queryKey: ["booking-milestones", bookingId] });
},
});
const sorted = useMemo(
() => [...milestones].sort((a, b) => a.sortOrder - b.sortOrder),
[milestones],
);
const nextPending = sorted.find((m) => m.status === "PENDING");
const dutyAdvised = milestones.find(
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
);
const dutyPaid = milestones.find((m) => m.milestoneCode === "DUTY_TAX_PAID");
const needsDutySlip =
dutyAdvised?.status === "COMPLETED" && dutyPaid?.status !== "COMPLETED";
if (sorted.length === 0) return null;
return (
<SectionCard>
<CardTitle>Shipment tracking</CardTitle>
{needsDutySlip ? (
<Box
mt="sm"
p="md"
style={{
borderRadius: 12,
border: "1px solid var(--mantine-color-yellow-3)",
background: "var(--mantine-color-yellow-0)",
}}
>
<Group gap={8} mb={6}>
<Receipt size={16} />
<Text fw={600} fz="sm">
Duty &amp; tax due
</Text>
</Group>
<Text fz="xs" c="dimmed" mb="sm">
Global Logistics advised
{dutyAdvised?.metadata?.dutyAmount != null
? ` ${dutyAdvised.metadata.dutyAmount.toLocaleString()} ${dutyAdvised.metadata.dutyCurrency ?? ""}`
: ""}
{dutyAdvised?.metadata?.declarationSerial
? ` · serial ${dutyAdvised.metadata.declarationSerial}`
: ""}
. Upload your payment slip to proceed.
</Text>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setSlip} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<Upload size={14} />}
>
{slip ? slip.name : "Choose slip"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="edr-green"
loading={uploadSlip.isPending}
disabled={!slip}
onClick={() => slip && uploadSlip.mutate(slip)}
>
Upload slip
</Button>
</Group>
</Box>
) : null}
<Stack gap={0} mt="md">
{sorted.map((m, index) => {
const isLast = index === sorted.length - 1;
const isNext = nextPending?.id === m.id;
const Icon =
m.status === "COMPLETED" ? Check : isNext ? Clock : Circle;
const risk =
m.milestoneCode === "RISK_ASSIGNED"
? m.metadata?.riskLevel
: undefined;
return (
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
<ThemeIcon
variant={m.status === "COMPLETED" ? "filled" : "light"}
color={isNext ? "edr-green" : "gray"}
radius="xl"
size={26}
>
<Icon size={13} strokeWidth={2.2} />
</ThemeIcon>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
minHeight: 22,
background:
m.status === "COMPLETED"
? "var(--mantine-color-edr-green-4)"
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Stack>
<Box pb={isLast ? 0 : "sm"} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text
fz="sm"
fw={m.status === "COMPLETED" ? 600 : 500}
c={m.status === "COMPLETED" ? undefined : "dimmed"}
>
{m.milestoneLabel}
</Text>
{risk ? (
<Badge size="xs" color={RISK_COLOR[risk]} variant="filled">
{risk}
</Badge>
) : null}
</Group>
</Box>
</Group>
);
})}
</Stack>
</SectionCard>
);
}

View File

@@ -17,6 +17,7 @@ import {
Title,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
@@ -147,6 +148,17 @@ export default function ContractDetailPage() {
enabled: !!id,
});
// Clearance view — drives the "documents need correction" alert / query count.
const inClearance =
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
const { data: clearanceView } = useQuery({
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
enabled: !!id && inClearance,
});
const queriedCount = (clearanceView?.documents ?? []).filter(
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
).length;
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
@@ -403,6 +415,56 @@ export default function ContractDetailPage() {
{/* Clearance notice (both paths) */}
{queriedCount > 0 && (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: "#F0B4B4",
background: "#FDF4F4",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: "#D64545",
}}
/>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Group gap={10} align="flex-start" wrap="nowrap">
<AlertTriangle size={18} color="#D64545" style={{ marginTop: 2 }} />
<div>
<Text fw={700} fz={15} c="#7A1F1F">
{queriedCount} document{queriedCount > 1 ? "s" : ""} need
correction
</Text>
<Text fz={13} c="#9A4A4A" mt={2}>
A reviewer sent back document
{queriedCount > 1 ? "s" : ""} with a query. Re-upload the
corrected file{queriedCount > 1 ? "s" : ""} to continue.
</Text>
</div>
</Group>
<Button
color="red"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
Upload corrected documents
</Button>
</Group>
</Paper>
)}
{canUploadClearance && (
<Paper
withBorder

View File

@@ -262,4 +262,17 @@ export const contractsService = {
const { data } = await client.get(C.BOOKING_MILESTONES(bookingId));
return data.data ?? data;
},
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
uploadDutySlip: async (
bookingId: string,
file: File,
): Promise<{ milestoneCompleted: boolean }> => {
const form = new FormData();
form.append("duty_tax_receipt", file);
const { data } = await client.post(C.BOOKING_DUTY_SLIP(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
};

View File

@@ -235,6 +235,8 @@ GL finalize (POST .../clearance/finalize)
A **cycle** is one clearance round. ONE_TIME contracts have a single cycle (#1). GENERAL contracts open a new cycle each time they need clearance before the next shipment.
> Clearance (section 3) is **pre-booking**. After GL creates the booking, the work continues as **GL Phase 2** — see section 4b.
---
## 4) Booking
@@ -299,6 +301,98 @@ payment status: PENDING → VERIFICATION_IN_PROGRESS → PAID (or FAILED)
---
## 4b) Global Logistics — Phase 2 (after the booking exists)
> Customs (Path B) shipments keep moving through GL after booking. This phase is
> a **milestone timeline** plus a set of **structured GL actions**. The customer
> only watches and, when asked, pays / uploads a duty slip.
### Milestone timeline
When GL creates the booking, the system seeds the **post-booking milestones**
for that direction (import ~15, export ~11). Each is `PENDING → COMPLETED`.
```
Import (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_SETTLED → WAGON_ALLOCATED
→ GATEPASS_GRANTED → READY_FOR_LOADING → LOADED → DEPARTED_FROM_DJIBOUTI
→ ARRIVED_ETHIOPIA → OFFLOADED → T1_CLOSED → RISK_ASSIGNED
→ IMPORT_RELEASE_GRANTED → IMPORT_PROCESS_COMPLETED
→ STORAGE_INVOICE_RAISED → EXIT_NOTE_GENERATED
Export (post-booking): WAGON_REQUESTED → FREIGHT_PAYMENT_PENDING → FREIGHT_PAYMENT_SETTLED
→ WAGON_ALLOCATED → CARGO_ARRIVED → READY_FOR_LOADING → LOADED
→ DEPARTED_TO_DJIBOUTI → ARRIVED_AT_DJIBOUTI → GATEPASS_GRANTED → OFFLOADED
```
Each milestone has an **owner**: ET (GL Ethiopia), DJ (GL Djibouti), OPS (Operations),
CUST (customer). Backoffice shows the timeline with a **Complete** button on the
next pending step; the customer portal shows the same timeline **read-only**.
### GL actions (the structured part)
Plain "Complete" covers most steps. These carry extra data, so they have their
own UI cards on the backoffice **milestones page** (`GlActionsPanel`):
```
Station routing → route shipment to a station yard (+ bind GL staff) (GL US-02)
Customs risk → assign GREEN / YELLOW / RED → completes RISK_ASSIGNED
Duty & tax → GL advises amount + declaration serial → completes
DUTY_TAXES_ADVISED → customer uploads slip → DUTY_TAX_PAID
GL documents → upload DO / RO / T1 / import release / interchange /
final declaration → auto-completes the matching milestone
Cargo exception → log SEAL_BROKEN / CONTAINER_OPENED / CONTAINER_DAMAGED /
FLUID_LEAKING with photos → alert GL Ethiopia (GL US-07)
```
**Doc-triggered milestones:** uploading the mapped document completes the
milestone automatically — no separate click:
| Upload (code) | Completes milestone | Who |
|---------------|---------------------|-----|
| `delivery_order` | DO_COLLECTED | GL DJ |
| `release_order` | RELEASE_ORDER_SECURED | GL DJ |
| `t1_transport_document` | T1_CLOSED | GL ET |
| `import_release` | IMPORT_RELEASE_GRANTED | GL ET |
| `full_in_interchange` | OFFLOADED | GL DJ |
| `final_declaration` | IMPORT_PROCESS_COMPLETED | GL ET |
| `duty_tax_receipt` | DUTY_TAX_PAID | **Customer** |
### ET ↔ DJ handoff
```
DEPARTED_FROM_DJIBOUTI (import) → lead returns to GL Ethiopia + Operations
DEPARTED_TO_DJIBOUTI (export) → lead moves to GL Djibouti
```
Ownership region is encoded per-milestone in the catalog; notifications fire on
handoff (notification module pending).
### What the customer does in Phase 2
```
watch the timeline (read-only)
pay duty/tax → upload payment slip (only when GL advised it)
pay freight → Pay button on the booking when batch-selected
that's all — every other step is GL / Ops / Terminal
```
### Where it lives (Phase 2)
| Area | Files |
|------|-------|
| Milestone seed/advance | `api/.../contracts/clearance-milestone.service.ts`, `clearance-milestone.catalog.ts` |
| GL actions (risk/duty/station/docs/incident) | `api/.../contracts/gl-operations.service.ts`, `dto/gl-operations.dto.ts`, `entities/clearance-incident.entity.ts` |
| Endpoints | `api/.../contracts/contracts.controller.ts` (`bookings/:id/risk` · `/duty` · `/station-assign` · `/documents` · `/incidents` · `/duty-slip`) |
| Backoffice UI | `backoffice/.../pages/contracts/BookingMilestonesPage.tsx`, `components/contracts/ClearanceMilestoneTimeline.tsx`, `components/contracts/gl-actions/*` |
| Portal UI | `portal/.../bookings/BookingDetailPage/components/ShipmentTrackingCard.tsx` |
### Still out of scope (per design doc §18)
Demurrage auto-calc & storage invoicing, finance AP closure, multimodal
(sea/air + MTO/OBL/HBL), truck waybill PDF + POD signing. `STORAGE_INVOICE_RAISED`
and `EXIT_NOTE_GENERATED` exist as **manual milestones** only — no fee engine yet.
---
## 5) Schedule (Operations)
> Goal: put the booking on a train (or dispatch by road). Day-level pooling — the
@@ -371,6 +465,12 @@ BOOKING
created by CUSTOMER (Path A / domestic) or GL (Path B)
price → pay → (its own doc clearance if applicable) → ready to schedule
GL PHASE 2 (customs/Path B, after booking)
milestone timeline: wagon → pay → allocate → load → depart → handover
→ arrive → offload → T1 close → risk → release → complete
GL actions: station routing · risk (G/Y/R) · duty advise · DO/RO/T1 upload · incident
customer: watch read-only · upload duty slip · pay freight
SCHEDULE
request a day → Operations accept → batch engine → train assigned
→ pay → IN_TRANSIT → COMPLETED

View File

@@ -263,6 +263,17 @@ export enum ContractDocPhase {
export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED";
export const CUSTOMS_RISK_LEVELS = ["GREEN", "YELLOW", "RED"] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/** Structured payload carried by RISK_ASSIGNED / DUTY_TAXES_ADVISED milestones. */
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
}
export interface IClearanceMilestone {
id: string;
bookingId?: string | null;
@@ -276,9 +287,31 @@ export interface IClearanceMilestone {
triggeredAt?: string | null;
triggeredByUserId?: string | null;
note?: string | null;
metadata?: MilestoneMetadata | null;
sortOrder: number;
}
// ── GL cargo exception / damage reports (doc §11 GL Import US-07) ────────────
export const INCIDENT_TYPES = [
"SEAL_BROKEN",
"CONTAINER_OPENED",
"CONTAINER_DAMAGED",
"FLUID_LEAKING",
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];
export interface IClearanceIncident {
id: string;
bookingId: string;
incidentType: IncidentType;
description: string;
photoFileIds: string[];
reportedByUserId?: string | null;
reportedAt: string;
createdAt: string;
}
export const IMPORT_MILESTONES = [
"IMPORT_DOCS_UPLOADED",
"PENDING_DOCUMENT_REVIEW",