Enhance clearance milestone management and introduce new contract actions

- Added new milestones for 'Transit Permit Uploaded' and 'Export Transport Document Issued' in the clearance milestone catalog.
- Implemented methods in ClearanceMilestoneService to skip milestones and complete them with metadata.
- Updated ContractBookingService to check boundary conditions before booking creation.
- Introduced new endpoints in ContractsController for uploading customs declarations, advising duty, and handling various document uploads.
- Enhanced the UI to support new clearance actions and display relevant components based on milestone statuses.
This commit is contained in:
marshal
2026-07-01 10:20:16 +03:00
parent ccd5d6de31
commit 612df8daff
36 changed files with 3018 additions and 57 deletions

View File

@@ -0,0 +1,112 @@
import { Check } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
const PHASE_LABELS: Record<string, string> = {
CUSTOMER_INTAKE: "Customer docs",
GL_ET_REVIEW: "GL ET review",
GL_DJ_COLLECTION: "GL Djibouti",
GL_ET_OUTPUT: "Declaration",
CUSTOMER_DUTY: "Duty / tax",
GL_ET_POST_CLEARANCE: "ET clearance",
GL_DJ_LOADING: "Loading",
POST_TRANSIT: "Transit",
};
const IMPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
"GL_ET_OUTPUT",
"CUSTOMER_DUTY",
"GL_ET_POST_CLEARANCE",
"GL_DJ_COLLECTION",
] as const;
const EXPORT_PHASES = [
"CUSTOMER_INTAKE",
"GL_ET_REVIEW",
"GL_DJ_COLLECTION",
"GL_ET_OUTPUT",
"GL_ET_POST_CLEARANCE",
] as const;
function phaseIndex(phases: readonly string[], current?: string | null): number {
if (!current) return 0;
const idx = phases.indexOf(current);
return idx >= 0 ? idx : 0;
}
export function ClearancePhaseStepper({
clearance,
tradeDirection,
compact = false,
}: {
clearance?: Freight.ContractClearanceView | null;
tradeDirection?: string;
compact?: boolean;
}) {
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
const current = clearance?.phase ?? phases[0];
const activeIdx = phaseIndex(phases, current);
return (
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
{phases.map((phase, index) => {
const isComplete = index < activeIdx;
const isActive = index === activeIdx;
const isLast = index === phases.length - 1;
return (
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: compact ? 28 : 34,
height: compact ? 28 : 34,
borderRadius: "50%",
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-3)",
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
}}
>
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
</Box>
<Text
size={compact ? "10px" : "xs"}
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{PHASE_LABELS[phase] ?? phase}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 6,
marginBottom: compact ? 16 : 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
);
}

View File

@@ -0,0 +1,481 @@
import { useState } from "react";
import {
Alert,
Button,
FileInput,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, FileText, Receipt, Ship, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ActionShell } from "@/components/contracts/gl-actions/ActionShell";
import { contractsService } from "@/services/contracts.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type RoleMode = "ET" | "DJ" | "ALL";
export function PhasedClearanceActionPanel({
contractId,
clearance,
tradeDirection,
roleMode = "ALL",
onChanged,
bookingCreateHref,
}: {
contractId: string;
clearance: Freight.ContractClearanceView;
tradeDirection: string;
roleMode?: RoleMode;
onChanged?: () => void;
bookingCreateHref?: string;
}) {
const { user } = useAuth();
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
const showEt = roleMode === "ET" || roleMode === "ALL";
const showDj = roleMode === "DJ" || roleMode === "ALL";
const next = clearance.nextAction;
const isImport = tradeDirection === "IMPORT";
return (
<Stack gap="md">
{clearance.roHold && clearance.roHoldReason ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
{clearance.roHoldReason}
</Alert>
) : null}
{next ? (
<Alert color="blue" variant="light" title="Next step">
<Text size="sm">
<strong>{next.actor.replace("_", " ")}</strong> {next.action}
</Text>
</Alert>
) : null}
{showEt && canEt && isImport ? (
<DeclarationCard contractId={contractId} onChanged={onChanged} />
) : null}
{showEt && canEt && isImport ? (
<DutyCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
) : null}
{showEt && canEt && isImport ? (
<TransitPermitCard contractId={contractId} onChanged={onChanged} />
) : null}
{showDj && canDj && isImport ? (
<DeliveryOrderCard contractId={contractId} onChanged={onChanged} />
) : null}
{showDj && canDj && !isImport ? (
<ReleaseOrderCard
contractId={contractId}
clearance={clearance}
onChanged={onChanged}
/>
) : null}
{showEt && canEt && !isImport ? (
<DeclarationCard contractId={contractId} onChanged={onChanged} exportMode />
) : null}
{showEt && canEt && !isImport ? (
<ExportReleaseCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
) : null}
{clearance.bookingReady && bookingCreateHref && showEt && canEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<Text size="sm" c="dimmed" mb="sm">
Pre-booking clearance is complete. Create the shipment booking for the customer.
</Text>
<Button component="a" href={bookingCreateHref} color="edr-green">
Create shipment booking
</Button>
</SectionCard>
) : null}
</Stack>
);
}
function DeclarationCard({
contractId,
onChanged,
exportMode = false,
}: {
contractId: string;
onChanged?: () => void;
exportMode?: boolean;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({});
const [loading, setLoading] = useState(false);
const fields = exportMode
? [
{ key: "ex3", label: "EX3" },
{ key: "ex8", label: "EX8" },
]
: [
{ key: "im4", label: "IM4" },
{ key: "im5", label: "IM5 (optional)" },
];
return (
<ActionShell
icon={FileText}
title="Customs declaration"
subtitle={exportMode ? "Upload EX3 / EX8" : "Upload IM4 / IM5"}
done={false}
>
<Stack gap="sm">
{fields.map((f) => (
<FileInput
key={f.key}
label={f.label}
placeholder="Choose file"
value={files[f.key] ?? null}
onChange={(file) => setFiles((prev) => ({ ...prev, [f.key]: file }))}
size="sm"
/>
))}
<Button
color="edr-green"
loading={loading}
leftSection={<Upload size={16} />}
onClick={async () => {
setLoading(true);
try {
await contractsService.uploadDeclaration(contractId, files);
toast.success("Declaration uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Submit declaration
</Button>
</Stack>
</ActionShell>
);
}
function DutyCard({
contractId,
clearance,
onChanged,
}: {
contractId: string;
clearance: Freight.ContractClearanceView;
onChanged?: () => void;
}) {
const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const [loading, setLoading] = useState(false);
const advised = clearance.milestones?.some(
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED",
);
return (
<ActionShell
icon={Receipt}
title="Duty & tax"
subtitle="Toggle whether duty applies and advise the amount"
done={advised && clearance.dutyRequired === false}
doneLabel={clearance.dutyRequired === false ? "Not required" : advised ? "Advised" : undefined}
>
<Stack gap="sm">
<Switch
label="Customer must pay duty/tax"
checked={dutyRequired}
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
/>
{dutyRequired ? (
<>
<Group grow>
<NumberInput label="Amount" value={amount} onChange={setAmount} min={0} size="sm" />
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<TextInput
label="Declaration / payment code"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
</>
) : null}
<Button
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.adviseContractDuty(contractId, {
dutyRequired,
amount: dutyRequired ? Number(amount) : undefined,
currency,
declarationSerial: serial || undefined,
});
toast.success(dutyRequired ? "Duty advised" : "Duty step skipped");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Save duty settings
</Button>
</Stack>
</ActionShell>
);
}
function TransitPermitCard({
contractId,
onChanged,
}: {
contractId: string;
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
return (
<ActionShell
icon={Upload}
title="Transit permit"
subtitle="Upload transit permitted screenshot"
done={false}
>
<Stack gap="sm">
<FileInput
label="Transit permit screenshot"
value={file}
onChange={setFile}
size="sm"
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadContractTransitPermit(contractId, file);
toast.success("Transit permit uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload transit permit
</Button>
</Stack>
</ActionShell>
);
}
function DeliveryOrderCard({
contractId,
onChanged,
}: {
contractId: string;
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
return (
<ActionShell
icon={Ship}
title="Delivery Order"
subtitle="GL Djibouti uploads the DO"
done={false}
>
<Stack gap="sm">
<FileInput label="Delivery Order" value={file} onChange={setFile} size="sm" />
<Button
color="edr-green"
loading={loading}
disabled={!file}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadDeliveryOrder(contractId, file);
toast.success("Delivery Order uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload DO
</Button>
</Stack>
</ActionShell>
);
}
function ReleaseOrderCard({
contractId,
clearance,
onChanged,
}: {
contractId: string;
clearance: Freight.ContractClearanceView;
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [vesselDate, setVesselDate] = useState<Date | null>(
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
const [amendLoading, setAmendLoading] = useState(false);
return (
<ActionShell
icon={Ship}
title="Release Order"
subtitle="Upload RO and vessel departure date"
done={false}
>
<Stack gap="sm">
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
<DateInput
label="Vessel departure date"
value={vesselDate}
onChange={setVesselDate}
size="sm"
/>
<Group>
<Button
color="edr-green"
loading={loading}
disabled={!file || !vesselDate}
onClick={async () => {
if (!file || !vesselDate) return;
setLoading(true);
try {
const iso = vesselDate.toISOString().slice(0, 10);
const result = await contractsService.uploadReleaseOrder(
contractId,
file,
iso,
);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success("Release Order accepted");
}
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload RO
</Button>
<Button
variant="light"
color="orange"
loading={amendLoading}
onClick={async () => {
setAmendLoading(true);
try {
await contractsService.requestRoAmendment(
contractId,
"Port amendment requested — vessel window too short.",
);
toast.success("Amendment request recorded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setAmendLoading(false);
}
}}
>
Request amendment
</Button>
</Group>
</Stack>
</ActionShell>
);
}
function ExportReleaseCard({
contractId,
clearance,
onChanged,
}: {
contractId: string;
clearance: Freight.ContractClearanceView;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
const done = clearance.bookingReady;
return (
<ActionShell
icon={FileText}
title="Export release"
subtitle="Confirm customs clearance complete"
done={done}
doneLabel={done ? "Ready for booking" : undefined}
>
<Button
color="edr-green"
loading={loading}
disabled={done}
onClick={async () => {
setLoading(true);
try {
await contractsService.confirmExportRelease(contractId);
toast.success("Export released — ready for booking");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Confirm export release
</Button>
</ActionShell>
);
}

View File

@@ -9,6 +9,7 @@ import { AssignStationCard } from "./AssignStationCard";
import { AssignRiskCard } from "./AssignRiskCard";
import { AdviseDutyCard } from "./AdviseDutyCard";
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
import { TransportDocumentCard } from "./TransportDocumentCard";
import { IncidentReportCard } from "./IncidentReportCard";
export interface GlActionsPanelProps {
@@ -39,6 +40,16 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
[milestones],
);
const wagonMs = useMemo(
() => findMilestone(milestones, "WAGON_ALLOCATED"),
[milestones],
);
const transportMs = useMemo(
() => findMilestone(milestones, "EXPORT_TRANSPORT_ISSUED"),
[milestones],
);
const showTransport =
wagonMs?.status === "COMPLETED" && transportMs?.status !== "COMPLETED";
return (
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
@@ -56,6 +67,8 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
<GlDocumentUploadCard bookingId={bookingId} />
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
) : null}

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import { Button, FileInput, Stack } from "@mantine/core";
import { FileText } from "lucide-react";
import toast from "react-hot-toast";
import { ActionShell } from "./ActionShell";
import { contractsService } from "@/services/contracts.service";
export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
return (
<ActionShell
icon={FileText}
title="Export transport document"
subtitle="Upload after wagon allocation (GL Ethiopia)"
done={false}
>
<Stack gap="sm">
<FileInput
label="Transport document"
value={file}
onChange={setFile}
size="sm"
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadTransportDocument(bookingId, file);
toast.success("Transport document uploaded");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload document
</Button>
</Stack>
</ActionShell>
);
}