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

@@ -28,6 +28,9 @@ import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { useContractDetail } from "@/hooks/contracts/useContracts";
@@ -40,6 +43,7 @@ export default function ContractClearanceDetailPage() {
data: clearance,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
@@ -61,7 +65,7 @@ export default function ContractClearanceDetailPage() {
const reference = contract?.reference ?? "Clearance";
// Customs (Path B) hub. The customer always creates the booking in the portal
// after GL finalizes clearance — there is no GL "Create booking" action here.
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
const ready = clearance?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
const clearanceReadOnly = Boolean(
contract?.status &&
[
@@ -152,15 +156,23 @@ export default function ContractClearanceDetailPage() {
<ClearanceHero contract={contract} stats={stats} />
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
<Paper withBorder radius="md" p="lg">
<ClearancePhaseStepper
clearance={clearance}
tradeDirection={contract.tradeDirection}
/>
</Paper>
) : null}
{ready ? (
<Alert
color="edr-green"
radius="md"
icon={<PackageCheck size={16} />}
title="Clearance finalized"
title="Clearance complete"
>
Customs clearance is complete. The customer can now create the
shipment booking from the portal no further action is needed here.
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
</Alert>
) : null}
@@ -171,55 +183,81 @@ export default function ContractClearanceDetailPage() {
hideSummary
selfClear={false}
readOnly={clearanceReadOnly}
onChanged={() => void refetch()}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
<Stack gap="md">
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
<PhasedClearanceActionPanel
contractId={id!}
clearance={clearance}
tradeDirection={contract.tradeDirection}
roleMode="ALL"
onChanged={() => void refetch()}
bookingCreateHref={
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
}
/>
) : (
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
)}
</Stack>
</Grid.Col>
</Grid>
{clearance.milestones && clearance.milestones.length > 0 ? (
<ClearanceMilestoneTimeline
milestones={
clearance.milestones as Parameters<
typeof ClearanceMilestoneTimeline
>[0]["milestones"]
}
/>
) : null}
</Stack>
</PageContainer>
);

View File

@@ -0,0 +1,126 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
import { AlertCircle } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { useContractDetail } from "@/hooks/contracts/useContracts";
export default function GlClearanceDetailPage({
backTo,
breadcrumbsLabel,
roleMode,
}: {
backTo: string;
breadcrumbsLabel: string;
roleMode: "ET" | "DJ";
}) {
const { id } = useParams<{ id: string }>();
const { data: contract } = useContractDetail(id);
const {
data: clearance,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(id),
});
if (isLoading) {
return (
<PageContainer>
<Stack align="center" py={80}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Stack>
</PageContainer>
);
}
if (isError || !clearance || !contract) {
return (
<PageContainer>
<Alert color="red" icon={<AlertCircle size={16} />}>
Could not load clearance for this contract.
</Alert>
</PageContainer>
);
}
const bookingHref =
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={contract.reference}
backTo={backTo}
breadcrumbs={[
{ label: breadcrumbsLabel, href: backTo },
{ label: contract.reference },
]}
/>
<Paper withBorder radius="md" p="lg">
<ClearancePhaseStepper
clearance={clearance}
tradeDirection={contract.tradeDirection}
/>
</Paper>
<Grid>
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
{roleMode === "ET" ? (
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly={false}
onChanged={() => void refetch()}
/>
) : (
<SectionCard title="Contract context" accent="edr-green">
<Text size="sm" c="dimmed" mb="sm">
Review upstream status before uploading Djibouti documents.
</Text>
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly
/>
</SectionCard>
)}
</Grid.Col>
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
<PhasedClearanceActionPanel
contractId={id!}
clearance={clearance}
tradeDirection={contract.tradeDirection}
roleMode={roleMode}
onChanged={() => void refetch()}
bookingCreateHref={bookingHref}
/>
</Grid.Col>
</Grid>
{clearance.milestones && clearance.milestones.length > 0 ? (
<ClearanceMilestoneTimeline
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
/>
) : null}
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,63 @@
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data, isLoading } = useDjClearanceQueue();
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
/>
{isLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{(data?.items ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No contracts need Djibouti GL action right now.
</Text>
) : (
(data?.items ?? []).map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="cyan">
{c.tradeDirection}
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</PageContainer>
);
}

View File

@@ -0,0 +1,63 @@
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Flag } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
export default function GlEthiopiaClearanceListPage() {
const navigate = useNavigate();
const { data, isLoading } = useEtClearanceQueue();
return (
<PageContainer>
<PageHeader
title="GL Ethiopia — Clearance"
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
/>
{isLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{(data?.items ?? []).length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No contracts need ET GL action right now.
</Text>
) : (
(data?.items ?? []).map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Flag size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
{c.tradeDirection}
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</PageContainer>
);
}