Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx
Marshal 26d864a55e View manual (offline) payment channel settings
Confirm offline (bank transfer) invoice payment
2026-08-20 10:29:59 +00:00

406 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
Paper,
RingProgress,
Stack,
Text,
} from "@mantine/core";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
PackageCheck,
PackagePlus,
RotateCcw,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
import type { KpiItem } from "@/components/page";
import {
SectionCard,
BookingCompanyCard,
BookingContractCard,
} from "@/components/bookings/detail";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
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 { contractsService } from "@/services/contracts.service";
import { downloadBookingFile } from "@/services/files.service";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
// The same shipment is opened from several worklists (GL Ethiopia clearance,
// the Operations clearance-documents hub, shipment requests…), so "back" is
// whichever list sent us here. Deep links have no sender: fall back to the
// hub this user actually works in.
const backTo =
(location.state as { from?: string } | null)?.from ??
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions)
? "/dashboard/contracts/clearance"
: "/dashboard/contracts/clearance-documents");
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["clearance", id],
queryFn: () => bookingsService.getClearance(id!),
enabled: Boolean(id),
});
const { data: bookingMilestones } = useBookingMilestones(id);
// The originating shipment request carries the quantities the customer asked
// for (per container type, or bulk weight/items). The bare instance itself has
// no cargo until GL completes the booking, so surface the request here.
const { data: contractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", booking?.contractId],
queryFn: () => contractsService.listBookingRequests(booking!.contractId!),
enabled: Boolean(booking?.contractId),
});
const requestedLines = useMemo(
() =>
(contractRequests ?? []).find((r) => r.createdBookingId === id)
?.requestedLines ?? null,
[contractRequests, id],
);
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [clearance]);
const reference = booking?.reference ?? "Clearance";
// Phased customs clearance runs on every contract booking now — ONE_TIME and
// GENERAL alike; the persisted phase is what marks the workflow as running.
const isPhasedGeneral =
Boolean(booking?.customsClearingEnabled) && Boolean(clearance?.phase);
// Bare initiated instance whose clearance is done: GL completes the booking
// (container numbers, VGM, shipment day) via the completion form.
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
const canCompleteBooking =
booking?.status === "CLEARANCE_READY" &&
Boolean(booking?.contractId) &&
Boolean(booking?.customsClearingEnabled) &&
!(Number(booking?.totalAmount ?? 0) > 0) &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
// The completed booking expired unpaid at train dispatch. Its per-booking
// clearance is finished, so GL rebooks it onto a new day — the customer never
// re-requests the shipment or pays the clearance fee again.
const canRebookExpired =
booking?.status === "EXPIRED" &&
Boolean(booking?.contractId) &&
Number(booking?.totalAmount ?? 0) > 0 &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
// Operations sent this GL-created booking back for changes. Customs bookings
// are never self-booked (see BookingChangesRequestedAlert) — the note and the
// resubmit belong here, on the page GL works from, not the customer's portal.
const bookingNeedsChanges = booking?.status === "OPERATION_CHANGES_REQUESTED";
const isGlBookingOwner =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const changeRequestNote =
[...(booking?.reviewNotes ?? [])]
.filter((n) => n.type === "CHANGES_REQUESTED")
.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
// Documents stay reviewable for as long as the customer can still submit
// them — until the shipment is paid, not merely until clearance is
// finalized. `documentsOpen` is the server's own predicate (the same one
// both the upload and review endpoints gate on), so the buttons are shown
// exactly when the API would accept them.
const documentsClosed = clearance?.documentsOpen === false;
const workflowFiles =
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
);
}
if (isError || !clearance) {
return (
<PageContainer>
<PageHeader
title="Clearance not found"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: backTo },
{ label: "Not found" },
]}
/>
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldnt load this bookings clearance.
</Alert>
</PageContainer>
);
}
const direction = booking?.tradeDirection ?? "—";
const origin = booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
const destination =
booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? "Destination";
const kpis: KpiItem[] = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
{ label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" },
{ label: "Pending", value: stats.pending, icon: Clock, color: "gray" },
{ label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" },
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: backTo },
{ label: reference },
]}
meta={
<Group gap={6} wrap="wrap">
<Badge variant="light" color={direction === "IMPORT" ? "edr-green" : "gray"} radius="sm">
{direction}
</Badge>
{clearance.includesCustoms ? (
<Badge variant="light" color="edr-green" radius="sm" leftSection={<ShieldCheck size={12} />}>
Customs
</Badge>
) : null}
{clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)}
</Group>
}
subtitle={
<Group gap={8} wrap="nowrap">
<Text size="sm" c="dimmed" fw={600}>
{origin}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" c="dimmed" fw={600}>
{destination}
</Text>
</Group>
}
action={
canCompleteBooking ? (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : canRebookExpired ? (
<Button
color="edr-green"
radius="md"
leftSection={<RotateCcw size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete?copyFrom=${id}`,
)
}
>
Rebook shipment
</Button>
) : undefined
}
/>
{bookingNeedsChanges ? (
<BookingChangesRequestedAlert
bookingId={id!}
reference={booking?.reference}
note={changeRequestNote}
scheduledDate={booking?.scheduledDate}
canResubmit={isGlBookingOwner}
editHref={
booking?.contractId
? `/dashboard/contracts/${booking.contractId}/bookings/${id}/complete?copyFrom=${id}`
: undefined
}
onResubmitted={() => void refetch()}
/>
) : null}
<KpiStrip items={kpis} />
{requestedLines ? (
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
Requested cargo
</Text>
<RequestedCargoChips lines={requestedLines} size="sm" />
</Group>
) : null}
{isPhasedGeneral ? (
<Paper withBorder radius="md" p="lg">
<ClearancePhaseStepper
clearance={clearance as Freight.ContractClearanceView}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
/>
</Paper>
) : null}
<ClearanceOpsTabs
bookingId={id}
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
exchangeEntityId={id}
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={documentsClosed}
queriesLocked={documentsClosed}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
{booking ? <BookingCompanyCard booking={booking} /> : null}
{booking ? <BookingContractCard booking={booking} /> : null}
{isPhasedGeneral ? (
<PhasedClearanceActionPanel
bookingId={id!}
clearance={clearance}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
bookingMilestones={bookingMilestones ?? []}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>
) : (
<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>
}
/>
</Stack>
</SectionCard>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
}
/>
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
) : null}
</Stack>
{viewer}
</PageContainer>
);
}