mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
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 couldn’t load this booking’s 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>
|
||
);
|
||
}
|