finilize gl

This commit is contained in:
marshal
2026-07-02 12:06:17 +03:00
parent 9c18d086d7
commit 4fefe4f827
50 changed files with 5150 additions and 1793 deletions

View File

@@ -295,10 +295,10 @@ export default function BookingRequestDetailPage() {
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/milestones`)
navigate(`/dashboard/bookings/${booking.id}/clearance`)
}
>
View clearance milestones
View document clearance
</Button>
)}
{showContractButton && (

View File

@@ -25,6 +25,7 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail";
@@ -32,12 +33,16 @@ import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceRe
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const { view, viewer } = useFileViewer();
const { data: booking } = useBookingDetail(id);
const {
@@ -51,6 +56,8 @@ export default function DocumentClearanceDetailPage() {
enabled: Boolean(id),
});
const { data: bookingMilestones } = useBookingMilestones(id);
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
@@ -69,6 +76,16 @@ export default function DocumentClearanceDetailPage() {
booking?.contractKind === "GENERAL" &&
Boolean(clearance?.phase);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
const queriesLocked = Boolean(
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
);
const workflowFiles =
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
if (isLoading) {
return (
<PageContainer>
@@ -85,9 +102,9 @@ export default function DocumentClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Not found" },
]}
/>
@@ -103,9 +120,9 @@ export default function DocumentClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: reference },
]}
meta={
@@ -142,77 +159,94 @@ export default function DocumentClearanceDetailPage() {
</Paper>
) : null}
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
<ClearanceReviewSection bookingId={id!} hideSummary />
</Grid.Col>
<ClearanceOpsTabs
bookingId={id}
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
clearanceTab={
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
<ClearanceReviewSection
bookingId={id!}
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
onChanged={() => void refetch()}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
onChanged={() => void refetch()}
/>
) : (
<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>
}
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
<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>
)}
</Grid.Col>
</Grid>
) : (
<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>
)}
</Grid.Col>
</Grid>
}
/>
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
<ClearanceMilestoneTimeline
milestones={
clearance.milestones as Parameters<
typeof ClearanceMilestoneTimeline
>[0]["milestones"]
}
/>
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
) : null}
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -19,51 +19,31 @@ import {
AlertCircle,
ArrowRight,
CheckCircle2,
ClipboardList,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
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 { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useFileViewer } from "@/hooks/useFileViewer";
import { contractsService } from "@/services/contracts.service";
import { downloadBookingFile } from "@/services/files.service";
import { useContractDetail } from "@/hooks/contracts/useContracts";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type RoleMode = "ET" | "DJ" | "ALL";
function resolveRoleMode(
canReview: boolean,
canEt: boolean,
canDj: boolean,
): RoleMode {
if (canReview || (canEt && canDj)) return "ALL";
if (canEt) return "ET";
if (canDj) return "DJ";
return "ALL";
}
import {
useBookingMilestones,
useContractDetail,
} from "@/hooks/contracts/useContracts";
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt =
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
const canDj =
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
const roleMode = resolveRoleMode(canReview, canEt, canDj);
const { data: contract } = useContractDetail(id);
const {
data: clearance,
@@ -109,14 +89,20 @@ export default function ContractClearanceDetailPage() {
"EXPIRED",
].includes(contract.status),
);
const reviewReadOnly =
roleMode === "DJ" ? true : shipmentLocked;
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
const actionColSpan = roleMode === "DJ" ? 12 : 5;
const bookingHref =
roleMode === "ET" || roleMode === "ALL"
? `/dashboard/contracts/${id}/create-booking`
: undefined;
const linkedBookingId = useMemo(() => {
const cycle = contract?.clearanceCycles?.find(
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
);
return cycle?.bookingId ?? undefined;
}, [contract]);
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
const canCreateBooking = ready && !bookingAlreadyCreated;
const reviewReadOnly = shipmentLocked;
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
if (isLoading) {
return (
@@ -166,14 +152,23 @@ export default function ContractClearanceDetailPage() {
{ label: reference },
]}
meta={
ready ? (
bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Booking created
</Badge>
) : ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready customer books
Ready create booking
</Badge>
) : clearance.allApproved ? (
<Badge
@@ -199,7 +194,30 @@ export default function ContractClearanceDetailPage() {
<ClearanceHero contract={contract} stats={stats} />
{ready ? (
{bookingAlreadyCreated ? (
<Alert
color="blue"
radius="md"
icon={<PackageCheck size={16} />}
title="Shipment booking created"
>
GL Ethiopia has created the shipment booking for this contract.
{linkedBookingId ? (
<>
{" "}
<Text
component={Link}
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
inherit
fw={600}
c="blue.7"
>
View booking
</Text>
</>
) : null}
</Alert>
) : canCreateBooking ? (
<Alert
color="edr-green"
radius="md"
@@ -210,106 +228,103 @@ export default function ContractClearanceDetailPage() {
</Alert>
) : null}
<Grid>
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
{roleMode === "DJ" ? (
<SectionCard
icon={ClipboardList}
title="Contract context"
accent="edr-green"
>
<Text size="sm" c="dimmed" mb="sm">
Review upstream status before uploading Djibouti documents.
</Text>
<ClearanceOpsTabs
bookingId={linkedBookingId}
milestones={bookingMilestones}
showOpsTabs={Boolean(linkedBookingId)}
showWorkflowFilesTab={phasedCustoms}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
clearanceTab={
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly
readOnly={reviewReadOnly}
approvalsLocked={phasedCustoms && docReviewLocked}
queriesLocked={queriesLocked}
phasedCustoms={phasedCustoms}
onChanged={() => {
void refetch();
void refetchBookingMilestones();
}}
/>
</SectionCard>
) : (
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly={reviewReadOnly}
approvalsLocked={phasedCustoms && docReviewLocked}
phasedCustoms={phasedCustoms}
onChanged={() => void refetch()}
/>
)}
{workflowFiles.length > 0 ? (
<Box mt="lg">
<ClearanceWorkflowFilesPanel
files={workflowFiles}
onView={view}
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
/>
</Box>
) : null}
</Grid.Col>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
<Stack gap="md">
{phasedCustoms ? (
<PhasedClearanceActionPanel
contractId={id!}
clearance={clearance}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
roleMode={roleMode}
onChanged={() => void refetch()}
bookingCreateHref={ready ? bookingHref : 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>
<Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md">
{phasedCustoms ? (
<PhasedClearanceActionPanel
contractId={id!}
bookingId={linkedBookingId}
bookingMilestones={bookingMilestones ?? []}
clearance={clearance}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
onChanged={() => {
void refetch();
void refetchBookingMilestones();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
bookingCreateHref={canCreateBooking ? bookingHref : undefined}
bookingCreated={bookingAlreadyCreated}
/>
) : (
<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>
}
/>
</Stack>
{viewer}
</PageContainer>

View File

@@ -26,7 +26,6 @@ import {
RefreshCw,
Search,
ShieldCheck,
Ship,
ShipWheel,
Table as TableIcon,
Truck,
@@ -48,13 +47,12 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import {
useContractClearanceQueue,
useDjClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et" | "dj";
type QueueTab = "all" | "et";
interface ClearanceRow {
id: string;
@@ -68,8 +66,10 @@ interface ClearanceRow {
serviceTypeName: string;
customs: boolean;
status: string;
/** true once GL has finalized clearance — customer now books in the portal. */
/** true once GL has finalized clearance — customer may book in the portal. */
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
}
function yardLabel(
@@ -102,6 +102,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
};
}
@@ -143,6 +144,21 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
<Badge
size="sm"
variant="light"
color="blue"
radius="sm"
leftSection={<PackagePlus size={12} />}
>
Booking created
</Badge>
</Tooltip>
);
}
if (row.ready) {
return (
<Tooltip
@@ -169,25 +185,16 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
}
/**
* Document Clearance hub. Lists every customs (Path B) contract that still needs
* customs clearance — awaiting documents, under GL review, or finalized and
* waiting for the customer to create the booking in the portal. A single list,
* no queue/history/direction tabs.
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
* including after booking is created — stays visible for reference and follow-up.
*/
export default function ContractClearanceListPage() {
const navigate = useNavigate();
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
const defaultQueue: QueueTab = canReview
? "all"
: canEt
? "et"
: canDj
? "dj"
: "all";
const defaultQueue: QueueTab = canReview ? "all" : "et";
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
@@ -197,26 +204,19 @@ export default function ContractClearanceListPage() {
useContractClearanceQueue(queueTab === "all");
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
useEtClearanceQueue(queueTab === "et");
const { data: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
useDjClearanceQueue(queueTab === "dj");
const data =
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
const isLoading =
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
const isError =
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
const isFetching =
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
const data = queueTab === "et" ? etData : allData;
const isLoading = queueTab === "et" ? etLoading : allLoading;
const isError = queueTab === "et" ? etError : allError;
const isFetching = queueTab === "et" ? etFetching : allFetching;
const refetch = () => {
if (queueTab === "et") void refetchEt();
else if (queueTab === "dj") void refetchDj();
else void refetchAll();
};
const queueTabOptions = useMemo(() => {
const opts: { value: QueueTab; label: ReactNode }[] = [];
if (canReview || (canEt && canDj)) {
if (canReview) {
opts.push({
value: "all",
label: (
@@ -238,19 +238,8 @@ export default function ContractClearanceListPage() {
),
});
}
if (canDj) {
opts.push({
value: "dj",
label: (
<Group gap={6} wrap="nowrap">
<Ship size={15} />
<Box visibleFrom="sm">DJ queue</Box>
</Group>
),
});
}
return opts;
}, [canReview, canEt, canDj]);
}, [canReview, canEt]);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
@@ -261,7 +250,8 @@ export default function ContractClearanceListPage() {
() => ({
all: allRows.length,
ready: allRows.filter((r) => r.ready).length,
review: allRows.filter((r) => !r.ready).length,
booked: allRows.filter((r) => r.bookingCreated).length,
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
}),
[allRows],
);
@@ -407,7 +397,7 @@ export default function ContractClearanceListPage() {
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
meta={
<Badge
variant="light"
@@ -415,7 +405,7 @@ export default function ContractClearanceListPage() {
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} need clearance
{counts.all} in clearance
</Badge>
}
action={
@@ -436,7 +426,7 @@ export default function ContractClearanceListPage() {
loading={isLoading}
items={[
{
label: "Need clearance",
label: "In clearance",
value: counts.all,
icon: Inbox,
color: "edr-green",
@@ -448,8 +438,8 @@ export default function ContractClearanceListPage() {
color: "yellow",
},
{
label: "Ready — customer books",
value: counts.ready,
label: "Ready / booked",
value: counts.ready + counts.booked,
icon: PackageCheck,
color: "edr-green",
},

View File

@@ -0,0 +1,262 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
Stack,
Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
type GlClearanceDetail =
| {
kind: "contract";
reference: string;
tradeDirection: string;
clearance: Freight.ContractClearanceView;
}
| {
kind: "booking";
reference: string;
tradeDirection: string;
clearance: Freight.ClearanceView;
};
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
try {
const [clearance, contract] = await Promise.all([
contractsService.getClearance(id),
contractsService.getById(id),
]);
return {
kind: "contract",
reference: contract.reference,
tradeDirection: contract.tradeDirection,
clearance,
};
} catch {
const [clearance, booking] = await Promise.all([
bookingsService.getClearance(id),
bookingsService.getById(id),
]);
return {
kind: "booking",
reference: booking.reference,
tradeDirection: booking.tradeDirection,
clearance,
};
}
}
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["gl-clearance-detail", id],
queryFn: () => loadGlClearanceDetail(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 || !data) {
return (
<PageContainer>
<Alert color="red" icon={<AlertCircle size={16} />}>
Could not load clearance for this item.
</Alert>
</PageContainer>
);
}
const backTo = "/dashboard/gl-djibouti/clearance";
const workflowFiles = data.clearance.workflowFiles ?? [];
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
const isImport = data.tradeDirection === "IMPORT";
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo);
const vesselDepartureDate =
"vesselDepartureDate" in data.clearance
? (data.clearance.vesselDepartureDate ?? null)
: null;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={data.reference}
backTo={backTo}
breadcrumbs={[
{ label: "GL Djibouti Clearance", href: backTo },
{ label: data.reference },
]}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{data.tradeDirection}
</Badge>
}
action={
<Group gap="sm">
{isImport ? (
<Button
color="edr-green"
leftSection={<Upload size={16} />}
disabled={!canUploadDo}
onClick={() => setUploadKind("do")}
>
{hasDo ? "Replace DO" : "Upload DO"}
</Button>
) : (
<Button
color="edr-green"
leftSection={<Upload size={16} />}
onClick={() => setUploadKind("ro")}
>
{hasRo ? "Replace RO" : "Upload RO"}
</Button>
)}
</Group>
}
/>
<Tabs defaultValue="workflow" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="workflow" leftSection={<ClipboardList size={14} />}>
Clearance workflow
</Tabs.Tab>
<Tabs.Tab
value="documents"
leftSection={<FileText size={14} />}
rightSection={
workflowFileCount > 0 ? (
<Badge size="xs" variant="light" color="edr-green" circle>
{workflowFileCount}
</Badge>
) : undefined
}
>
Customs documents (all steps)
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workflow">
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
) : (
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly
phasedCustoms
/>
)}
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : undefined}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
</Grid.Col>
</Grid>
</Tabs.Panel>
<Tabs.Panel value="documents">
{workflowFiles.length > 0 ? (
<ClearanceWorkflowFilesPanel
files={workflowFiles}
title="Customs documents (all steps)"
onView={view}
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<Box
py={48}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={8} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={360}>
No customs workflow documents uploaded yet. Files from Ethiopia-side
clearance and your DO/RO uploads will appear here.
</Text>
</Stack>
</Box>
)}
</Tabs.Panel>
</Tabs>
</Stack>
<GlClearanceUploadModal
opened={uploadKind != null}
kind={uploadKind}
onClose={() => setUploadKind(null)}
entityId={id!}
isBooking={data.kind === "booking"}
workflowFiles={workflowFiles}
vesselDepartureDate={vesselDepartureDate}
onSuccess={() => void refetch()}
onPreview={view}
/>
{viewer}
</PageContainer>
);
}

View File

@@ -0,0 +1,123 @@
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
import { ChevronRight, Container, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
const contractItems = contractQueue?.items ?? [];
const bookingItems = bookingQueue ?? [];
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
/>
<Tabs defaultValue="contracts" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="contracts">
{contractsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
pre-clearance is finalized.
</Text>
) : (
contractItems.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="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="bookings">
{bookingsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs bookings yet.
</Text>
) : (
bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Container size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {b.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Booking
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</Tabs.Panel>
</Tabs>
</PageContainer>
);
}