Pre-declaration Djibouti GL assignee request flow

This commit is contained in:
Marshal
2026-07-28 09:01:23 +00:00
parent 653d5782ee
commit e1c831211f
18 changed files with 593 additions and 20 deletions

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>,