mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add GL operations for customs risk assignment, duty advising, and incident reporting
This commit is contained in:
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -426,6 +426,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||||
selectedForBatchAt?: Date | null;
|
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)
|
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||||
bookingContainers?: BookingContainer[];
|
bookingContainers?: BookingContainer[];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
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 { Contract } from './entities/contract.entity';
|
||||||
import {
|
import {
|
||||||
HANDOFF_MILESTONES,
|
HANDOFF_MILESTONES,
|
||||||
@@ -104,6 +108,85 @@ export class ClearanceMilestoneService {
|
|||||||
return saved;
|
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. */
|
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||||
async completeByDocTrigger(
|
async completeByDocTrigger(
|
||||||
scope: { bookingId?: string; contractId?: string },
|
scope: { bookingId?: string; contractId?: string },
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service';
|
|||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { CreateContractDto } from './dto/create-contract.dto';
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
import { UpdateContractDto } from './dto/update-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 { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
import {
|
||||||
|
AdviseDutyDto,
|
||||||
|
AssignRiskDto,
|
||||||
|
AssignStationDto,
|
||||||
|
CompleteMilestoneDto,
|
||||||
|
ReportIncidentDto,
|
||||||
|
} from './dto/gl-operations.dto';
|
||||||
|
|
||||||
@ApiTags('contracts')
|
@ApiTags('contracts')
|
||||||
@Controller('contracts')
|
@Controller('contracts')
|
||||||
@@ -69,6 +77,7 @@ export class ContractsController {
|
|||||||
private readonly clearanceService: ContractClearanceService,
|
private readonly clearanceService: ContractClearanceService,
|
||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly glOperationsService: GlOperationsService,
|
||||||
private readonly signaturesService: SignaturesService,
|
private readonly signaturesService: SignaturesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -506,4 +515,121 @@ export class ContractsController {
|
|||||||
body?.note,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ContractTransitionService } from './contract-transition.service';
|
|||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
|
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.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 { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.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 { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
@@ -50,6 +53,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractClearanceCycle,
|
ContractClearanceCycle,
|
||||||
ContractDocumentReview,
|
ContractDocumentReview,
|
||||||
ClearanceMilestone,
|
ClearanceMilestone,
|
||||||
|
ClearanceIncident,
|
||||||
|
Booking,
|
||||||
BookingContainerUnit,
|
BookingContainerUnit,
|
||||||
]),
|
]),
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
@@ -76,6 +81,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
|
GlOperationsService,
|
||||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||||
// helpers reused from src/contracts/.
|
// helpers reused from src/contracts/.
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,22 @@ export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
|||||||
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
|
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
|
||||||
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
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 (18–23 per direction). Pre-booking milestones attach
|
* A GL clearance milestone (18–23 per direction). Pre-booking milestones attach
|
||||||
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
|
* 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 })
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||||
|
metadata?: MilestoneMetadata | null;
|
||||||
|
|
||||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ export const QUERY_KEYS = {
|
|||||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||||
bookingMilestones: (bookingId: string) =>
|
bookingMilestones: (bookingId: string) =>
|
||||||
["contracts", "booking-milestones", bookingId] as const,
|
["contracts", "booking-milestones", bookingId] as const,
|
||||||
|
bookingIncidents: (bookingId: string) =>
|
||||||
|
["contracts", "booking-incidents", bookingId] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKING_ORDERS: {
|
BOOKING_ORDERS: {
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/bookings/${bookingId}/milestones`,
|
`/contracts/bookings/${bookingId}/milestones`,
|
||||||
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
|
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
|
||||||
`/contracts/bookings/${bookingId}/milestones/${code}/complete`,
|
`/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: {
|
OTP: {
|
||||||
|
|||||||
@@ -297,3 +297,106 @@ export function useCompleteMilestone(bookingId: string) {
|
|||||||
onError: () => toast.error("Failed to complete milestone"),
|
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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PageContainer } from "@/components/page";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||||
|
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
|
||||||
import {
|
import {
|
||||||
useBookingMilestones,
|
useBookingMilestones,
|
||||||
useCompleteMilestone,
|
useCompleteMilestone,
|
||||||
@@ -66,21 +67,30 @@ export default function BookingMilestonesPage() {
|
|||||||
|
|
||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<SectionCard icon={Flag} title="Clearance milestones">
|
<Stack gap="lg">
|
||||||
{isLoading ? (
|
<SectionCard icon={Flag} title="Clearance milestones">
|
||||||
<Center py="xl">
|
{isLoading ? (
|
||||||
<Loader color="edr-green" size="sm" />
|
<Center py="xl">
|
||||||
</Center>
|
<Loader color="edr-green" size="sm" />
|
||||||
) : (
|
</Center>
|
||||||
<ClearanceMilestoneTimeline
|
) : (
|
||||||
|
<ClearanceMilestoneTimeline
|
||||||
|
milestones={milestones ?? []}
|
||||||
|
busy={complete.isPending}
|
||||||
|
onComplete={(code, note) =>
|
||||||
|
complete.mutate({ code, note })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{id ? (
|
||||||
|
<GlActionsPanel
|
||||||
|
bookingId={id}
|
||||||
milestones={milestones ?? []}
|
milestones={milestones ?? []}
|
||||||
busy={complete.isPending}
|
|
||||||
onComplete={(code, note) =>
|
|
||||||
complete.mutate({ code, note })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</SectionCard>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
|||||||
@@ -250,4 +250,75 @@ export const contractsService = {
|
|||||||
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
|
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
|
||||||
{ note },
|
{ 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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export const URL_CONSTANTS = {
|
|||||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||||
|
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||||
|
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||||
},
|
},
|
||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function MyPortalPage() {
|
|||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
<HelloSection greeting={greeting} companyName={companyName} />
|
<HelloSection greeting={greeting} companyName={companyName} />
|
||||||
|
|
||||||
<ActionNeededSection items={actionItems} />
|
<ActionNeededSection items={actionItems} contracts={allContracts} />
|
||||||
|
|
||||||
{serviceOptions.length > 1 && (
|
{serviceOptions.length > 1 && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
|
|||||||
@@ -15,15 +15,46 @@ export interface ActionItem {
|
|||||||
urgent?: boolean;
|
urgent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only AWAITING is a pending CUSTOMER action (initial upload or re-upload after a
|
// Contract statuses that mean clearance is in progress (Path A or B). A queried
|
||||||
// query). UNDER_REVIEW is waiting on staff, so it doesn't belong on the card.
|
// document flips the contract back to AWAITING_CLEARANCE_DOCUMENTS, but the panel
|
||||||
const CLEARANCE_ACTION_STATUSES = ["AWAITING_CLEARANCE_DOCUMENTS"];
|
// 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
|
* 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
|
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
|
||||||
* AWAITING_CLEARANCE_DOCUMENTS after review means a document was queried and
|
* re-upload after a query) is flagged urgent so the home card shows an upload
|
||||||
* needs the customer's attention — flagged urgent.
|
* button. See {@link contractNeedsClearance}.
|
||||||
*/
|
*/
|
||||||
export function deriveActionItems(
|
export function deriveActionItems(
|
||||||
contracts: Freight.IContract[],
|
contracts: Freight.IContract[],
|
||||||
@@ -42,14 +73,17 @@ export function deriveActionItems(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (CLEARANCE_ACTION_STATUSES.includes(c.status)) {
|
const clr = contractNeedsClearance(c);
|
||||||
|
if (clr.show) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `clearance-${c.id}`,
|
id: `clearance-${c.id}`,
|
||||||
kind: "clearance",
|
kind: "clearance",
|
||||||
reference: c.reference,
|
reference: c.reference,
|
||||||
description: "Clearance documents needed",
|
description: clr.urgent
|
||||||
|
? "Clearance documents need your action"
|
||||||
|
: "Clearance under review",
|
||||||
targetId: c.id,
|
targetId: c.id,
|
||||||
urgent: true,
|
urgent: clr.urgent,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
@@ -18,6 +19,13 @@ import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/component
|
|||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
import type { ActionItem } from "../actions";
|
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<
|
const KIND_META: Record<
|
||||||
ActionItem["kind"],
|
ActionItem["kind"],
|
||||||
{ icon: typeof Upload; label: string; color: string }
|
{ icon: typeof Upload; label: string; color: string }
|
||||||
@@ -29,19 +37,86 @@ const KIND_META: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ActionNeededSectionProps {
|
export interface ActionNeededSectionProps {
|
||||||
|
/** Non-clearance actions (sign / book / pay) derived from status. */
|
||||||
items: ActionItem[];
|
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
|
* Home "needs your attention" card. Lists pending customer actions across
|
||||||
* contracts and bookings. Clearance and payment open in a modal right here;
|
* contracts and bookings. Clearance items are derived from the ACTUAL clearance
|
||||||
* sign and book navigate to the relevant page.
|
* 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 navigate = useNavigate();
|
||||||
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
||||||
const [payItem, setPayItem] = useState<ActionItem | 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
|
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
|
||||||
// redirect (clientAction.url); fall back to the public checkout page.
|
// redirect (clientAction.url); fall back to the public checkout page.
|
||||||
const payMutation = useMutation({
|
const payMutation = useMutation({
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
|||||||
import { PaymentCard } from "./components/pricing";
|
import { PaymentCard } from "./components/pricing";
|
||||||
import { ScheduleCard } from "./components/ScheduleCard";
|
import { ScheduleCard } from "./components/ScheduleCard";
|
||||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||||
|
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
@@ -146,6 +147,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
|
|
||||||
<ContainersCard booking={booking} />
|
<ContainersCard booking={booking} />
|
||||||
|
|
||||||
|
<ShipmentTrackingCard bookingId={booking.id} />
|
||||||
|
|
||||||
{booking.files && booking.files.length > 0 && (
|
{booking.files && booking.files.length > 0 && (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -147,6 +148,17 @@ export default function ContractDetailPage() {
|
|||||||
enabled: !!id,
|
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(
|
const contractBookings = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(bookingsPage?.items ?? []).filter(
|
(bookingsPage?.items ?? []).filter(
|
||||||
@@ -403,6 +415,56 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
|
|
||||||
{/* Clearance notice (both paths) */}
|
{/* 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 && (
|
{canUploadClearance && (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
|
|||||||
@@ -262,4 +262,17 @@ export const contractsService = {
|
|||||||
const { data } = await client.get(C.BOOKING_MILESTONES(bookingId));
|
const { data } = await client.get(C.BOOKING_MILESTONES(bookingId));
|
||||||
return data.data ?? data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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)
|
## 5) Schedule (Operations)
|
||||||
|
|
||||||
> Goal: put the booking on a train (or dispatch by road). Day-level pooling — the
|
> 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)
|
created by CUSTOMER (Path A / domestic) or GL (Path B)
|
||||||
price → pay → (its own doc clearance if applicable) → ready to schedule
|
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
|
SCHEDULE
|
||||||
request a day → Operations accept → batch engine → train assigned
|
request a day → Operations accept → batch engine → train assigned
|
||||||
→ pay → IN_TRANSIT → COMPLETED
|
→ pay → IN_TRANSIT → COMPLETED
|
||||||
|
|||||||
@@ -263,6 +263,17 @@ export enum ContractDocPhase {
|
|||||||
|
|
||||||
export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED";
|
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 {
|
export interface IClearanceMilestone {
|
||||||
id: string;
|
id: string;
|
||||||
bookingId?: string | null;
|
bookingId?: string | null;
|
||||||
@@ -276,9 +287,31 @@ export interface IClearanceMilestone {
|
|||||||
triggeredAt?: string | null;
|
triggeredAt?: string | null;
|
||||||
triggeredByUserId?: string | null;
|
triggeredByUserId?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
metadata?: MilestoneMetadata | null;
|
||||||
sortOrder: number;
|
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 = [
|
export const IMPORT_MILESTONES = [
|
||||||
"IMPORT_DOCS_UPLOADED",
|
"IMPORT_DOCS_UPLOADED",
|
||||||
"PENDING_DOCUMENT_REVIEW",
|
"PENDING_DOCUMENT_REVIEW",
|
||||||
|
|||||||
Reference in New Issue
Block a user