Merge pull request #992 from Tria-plc/freight_feature/usermanagement

Pre-declaration Djibouti GL assignee request flow
This commit is contained in:
marshal
2026-07-28 12:17:50 +03:00
committed by GitHub
18 changed files with 593 additions and 20 deletions

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake on the SHIPMENT, not the contract.
*
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
* answer belong on the booking. The contract-cycle columns added by
* 2950000000000 stay for the legacy contract-level cycles.
*/
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A contract's `created_at` is the DRAFT row's insert time, not when the
* customer actually submitted it for review — a DRAFT can sit edited for days
* first. `submitted_at` is stamped by ContractTransitionService.submit /
* confirmSubmit so the history UI can show a real submission time.
*/
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
name = 'AddContractSubmittedAt3020000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
);
}
}

View File

@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */

View File

@@ -823,6 +823,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -557,6 +557,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | 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;

View File

@@ -15,6 +15,9 @@ const generalImportBooking = {
dutyRequired: true,
roHoldReason: null,
vesselDepartureDate: null,
// Djibouti already named the transit officer — the declaration gate is open.
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
transitAssigneeName: 'Ahmed Bourhan',
} as Booking;
const generalExportBooking = {
@@ -27,6 +30,8 @@ const generalExportBooking = {
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
@@ -38,10 +43,14 @@ function makeService(overrides?: {
};
const filesService = {
upsertByCode: jest.fn().mockResolvedValue({}),
upload: jest.fn().mockResolvedValue({}),
deleteByCode: jest.fn().mockResolvedValue(undefined),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
getByCode: overrides?.docsApproved
? jest.fn().mockResolvedValue({ fields: [] })
: jest.fn().mockRejectedValue(new Error('no setting')),
};
const workflowService = {
assertPriorCompleteForBooking: overrides?.workflowThrows
@@ -49,6 +58,7 @@ function makeService(overrides?: {
: jest.fn().mockResolvedValue(undefined),
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
@@ -91,6 +101,8 @@ function makeService(overrides?: {
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
} as never, // notifier
);
@@ -186,6 +198,82 @@ describe('BookingClearanceService', () => {
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an import declaration before Djibouti names the transit officer', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toThrow(/Request a transit assignee/i);
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
});
it('lets the export declaration through without a transit assignee', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalExportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await service.uploadDeclaration('b-export', [
{ fieldname: 'decl' } as Express.Multer.File,
]);
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
});
});
describe('transit assignee handshake', () => {
it('refuses an assignment GL Ethiopia never asked for', async () => {
const { service } = makeService({
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
).rejects.toThrow(/has not requested a transit assignee/i);
});
it('stamps the ask and then the name', async () => {
const { service, bookingsRepository } = makeService({
booking: {
...generalImportBooking,
transitAssigneeName: null,
} as Booking,
});
await service.requestTransitAssignee('b-general', ' night shift ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeRequestedAt: expect.any(Date),
transitAssigneeRequestNote: 'night shift',
}),
);
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeName: 'Ahmed Bourhan',
transitAssigneeAssignedAt: expect.any(Date),
}),
);
});
});
describe('uploadReleaseOrder', () => {

View File

@@ -71,6 +71,17 @@ export interface BookingClearanceView {
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
/**
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the import customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -269,6 +280,16 @@ export class BookingClearanceService {
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
transitAssignee: {
requestedAt: booking.transitAssigneeRequestedAt
? booking.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: booking.transitAssigneeRequestNote ?? null,
name: booking.transitAssigneeName ?? null,
assignedAt: booking.transitAssigneeAssignedAt
? booking.transitAssigneeAssignedAt.toISOString()
: null,
},
dutyAdvice,
workflowFiles,
t1,
@@ -356,6 +377,53 @@ export class BookingClearanceService {
);
}
/**
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
* in transit. The import declaration is gated on the answer, so this is the
* first thing ET does once the customer documents are approved. Re-requesting
* is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
}
/**
* GL Djibouti names the transit officer — free text, because the person is not
* a platform user. Answering unblocks the declaration for Ethiopia. A later
* call overwrites the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
transitAssigneeName: assignee.trim(),
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
return this.bookingsService.findById(bookingId);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
@@ -369,6 +437,16 @@ export class BookingClearanceService {
'All required customer documents must be approved before uploading a declaration.',
);
}
// Import only: the declaration is filed against whoever physically handles
// the shipment in Djibouti, so that name must be in first. Exports have no
// such handshake — their Djibouti steps come after the declaration.
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {

View File

@@ -225,6 +225,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -241,6 +242,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);

View File

@@ -216,6 +216,10 @@ export class Contract extends BaseEntity {
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;

View File

@@ -0,0 +1,220 @@
import { useMemo } from "react";
import {
BadgeCheck,
CalendarClock,
Flame,
Send,
ShieldCheck,
FileSignature,
} from "lucide-react";
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import type { Freight } from "@edr/types";
import {
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
} from "@/lib/permissions";
interface ContractMilestonesTimelineProps {
contract: Freight.IContract;
}
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
CUSTOMER: "Signed by customer",
STAFF: "Signed by EDR — line staff",
DIRECTOR: "Signed by EDR — director",
CEO: "Signed by EDR — CEO",
};
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/** "3 hours ago" — the at-a-glance read. */
function formatAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, secondsPerUnit] of units) {
if (seconds >= secondsPerUnit) {
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
}
}
return "just now";
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
}
type MilestoneIcon = typeof Send;
interface Milestone {
key: string;
at: string;
title: string;
detail?: string;
color: string;
icon: MilestoneIcon;
}
/**
* The dated moments of a contract's life — submission, hazardous approval,
* final approval, both parties' signatures, full execution — read straight off
* the contract and its already-loaded approvalSteps/signatures (no extra
* fetch). Sits above the document edit history on the History tab.
*/
export function ContractMilestonesTimeline({
contract,
}: ContractMilestonesTimelineProps) {
const milestones = useMemo<Milestone[]>(() => {
const items: Milestone[] = [];
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
// to date. submittedAt is only tracked going forward; a contract that
// reached SUBMITTED before that column existed falls back to createdAt.
const submittedAt =
contract.submittedAt ??
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
? contract.createdAt
: null);
if (submittedAt) {
items.push({
key: "submitted",
at: submittedAt,
title: "Submitted for review",
color: "blue",
icon: Send,
});
}
for (const step of contract.approvalSteps ?? []) {
if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue;
if (!step.actedAt) continue;
items.push({
key: `hazard-${step.id}`,
at: step.actedAt,
title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole,
detail: step.status === "REJECTED" ? "Rejected" : "Approved",
color: step.status === "REJECTED" ? "red" : "orange",
icon: Flame,
});
}
if (contract.contractGeneratedAt) {
items.push({
key: "approved",
at: contract.contractGeneratedAt,
title: "Contract approved",
detail: "Every approval step cleared and the document was generated",
color: "edr-green",
icon: ShieldCheck,
});
}
for (const sig of contract.signatures ?? []) {
items.push({
key: `signature-${sig.id}`,
at: sig.signedAt,
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
detail: sig.signerDisplayName,
color: "grape",
icon: FileSignature,
});
}
if (contract.fullyExecutedAt) {
items.push({
key: "executed",
at: contract.fullyExecutedAt,
title: "Fully executed",
detail: "Both parties have signed",
color: "edr-green",
icon: BadgeCheck,
});
}
return items.sort(
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
);
}, [contract]);
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
if (milestones.length === 0 && !hasValidity) {
return (
<Text size="sm" c="dimmed" ta="center" py="lg">
No dated milestones recorded yet.
</Text>
);
}
return (
<Stack gap="md">
{hasValidity && (
<Group
gap="xs"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
width: "fit-content",
}}
>
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
<Text size="sm">
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
{" → "}
<strong>{formatDate(contract.contractValidUntil!)}</strong>
</Text>
</Group>
)}
{milestones.length > 0 && (
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
{milestones.map((m) => {
const Icon = m.icon;
return (
<Timeline.Item
key={m.key}
bullet={<Icon size={13} />}
color={m.color}
title={
<Group gap="xs" wrap="wrap" align="baseline">
<Text size="sm" fw={600}>
{m.title}
</Text>
<Tooltip label={formatWhen(m.at)} withArrow>
<Text size="xs" c="dimmed">
{formatAgo(m.at)}
</Text>
</Tooltip>
</Group>
}
>
{m.detail && (
<Text size="xs" c="dimmed" mt={2}>
{m.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
)}
</Stack>
);
}

View File

@@ -349,11 +349,11 @@ export function PhasedClearanceActionPanel({
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!isBooking &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
contractId={entityId}
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}

View File

@@ -15,10 +15,14 @@ import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
export interface TransitAssigneePanelProps {
contractId: string;
/** Booking id when `isBooking`, contract id otherwise. */
entityId: string;
/** Clearance runs per booking now; contract-level cycles are the legacy case. */
isBooking?: boolean;
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
/**
* ET asks and waits; DJ answers with a name. The same state renders from both
@@ -50,7 +54,8 @@ const fmt = (iso?: string | null) =>
* different name later; the newest one wins and Ethiopia is notified again.
*/
export function TransitAssigneePanel({
contractId,
entityId,
isBooking = false,
transitAssignee,
side,
readOnly = false,
@@ -59,9 +64,12 @@ export function TransitAssigneePanel({
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [changing, setChanging] = useState(false);
const service = isBooking ? bookingsService : contractsService;
const request = useMutation({
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
mutationFn: async () => {
await service.requestTransitAssignee(entityId, note.trim());
},
onSuccess: () => {
toast.success("Request sent to GL Djibouti");
setNote("");
@@ -70,8 +78,9 @@ export function TransitAssigneePanel({
});
const assign = useMutation({
mutationFn: () =>
contractsService.assignTransitAssignee(contractId, assignee.trim()),
mutationFn: async () => {
await service.assignTransitAssignee(entityId, assignee.trim());
},
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
setChanging(false);

View File

@@ -144,6 +144,10 @@ export const URL_CONSTANTS = {
CLEARANCE_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/assign`,
CLEARANCE_FINALIZE_PRE: (id: string) =>
`/bookings/${id}/clearance/finalize-pre-clearance`,
CLEARANCE_TRANSIT_PERMIT: (id: string) =>

View File

@@ -16,6 +16,7 @@ import {
Flame,
History,
LayoutGrid,
Milestone,
Package,
Receipt,
RefreshCw,
@@ -54,6 +55,7 @@ import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprov
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline";
import {
ContractCustomerCard,
ContractDocumentsCard,
@@ -531,13 +533,22 @@ export default function ContractRequestDetailPage() {
) : null}
</Stack>
) : currentTab === "history" ? (
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
<Stack gap="lg">
<SectionCard
icon={Milestone}
title="Key milestones"
subtitle="Submission, approval, signatures and validity — the dated record of this contract."
>
<ContractMilestonesTimeline contract={contract} />
</SectionCard>
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
</Stack>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (

View File

@@ -236,13 +236,15 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the customs declaration until this desk
names the officer handling the shipment in transit, so the ask
sits above everything else on the page. */}
{data.kind === "contract" ? (
{/* GL Ethiopia cannot file the import customs declaration until this
desk names the officer handling the shipment in transit, so the
ask sits above everything else on the page. Exports have no such
gate — Djibouti's steps come after the declaration. */}
{isImport ? (
<Box mb="md">
<TransitAssigneePanel
contractId={id!}
entityId={id!}
isBooking={data.kind === "booking"}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={

View File

@@ -332,6 +332,18 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
note,
}),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
}),
uploadDeclaration: async (
id: string,
files: Record<string, File | null>,

View File

@@ -752,6 +752,9 @@ export interface IContract extends BaseEntity {
contractValidUntil?: string | null;
expiresAt?: string | null;
/** When the customer last submitted this contract for review. */
submittedAt?: string | null;
status: ContractStatus;
/**
* Body of the latest staff CHANGES_REQUESTED review note (detail response

View File

@@ -783,6 +783,17 @@ export interface ClearanceView {
/** Boundary milestone complete — customer may proceed to operations. */
operationReady?: boolean;
preClearanceFinalized?: boolean;
/**
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the import customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;