mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { useMemo } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useParams } from "react-router-dom";
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Box,
|
||
Grid,
|
||
Group,
|
||
Loader,
|
||
Paper,
|
||
Progress,
|
||
RingProgress,
|
||
Stack,
|
||
Text,
|
||
ThemeIcon,
|
||
} from "@mantine/core";
|
||
import {
|
||
AlertCircle,
|
||
ArrowRight,
|
||
CheckCircle2,
|
||
Clock,
|
||
PackageCheck,
|
||
ShieldCheck,
|
||
} 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";
|
||
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 { 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 {
|
||
data: clearance,
|
||
isLoading,
|
||
isError,
|
||
refetch,
|
||
} = useQuery({
|
||
queryKey: ["clearance", id],
|
||
queryFn: () => bookingsService.getClearance(id!),
|
||
enabled: Boolean(id),
|
||
});
|
||
|
||
const { data: bookingMilestones } = useBookingMilestones(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";
|
||
const isPhasedGeneral =
|
||
Boolean(booking?.customsClearingEnabled) &&
|
||
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>
|
||
<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="/dashboard/contracts/clearance"
|
||
breadcrumbs={[
|
||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||
{ label: "Not found" },
|
||
]}
|
||
/>
|
||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||
We couldn’t load this booking’s clearance.
|
||
</Alert>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PageContainer>
|
||
<Stack gap="lg">
|
||
<PageHeader
|
||
title={reference}
|
||
backTo="/dashboard/contracts/clearance"
|
||
breadcrumbs={[
|
||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||
{ label: reference },
|
||
]}
|
||
meta={
|
||
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>
|
||
)
|
||
}
|
||
/>
|
||
|
||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||
|
||
{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}
|
||
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"}
|
||
workflowFiles={workflowFiles}
|
||
roleMode="ET"
|
||
onChanged={() => void refetch()}
|
||
onViewFile={view}
|
||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||
/>
|
||
) : (
|
||
<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} />
|
||
) : null}
|
||
</Stack>
|
||
{viewer}
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
function ClearanceHero({
|
||
booking,
|
||
clearance,
|
||
stats,
|
||
}: {
|
||
booking: ReturnType<typeof useBookingDetail>["data"];
|
||
clearance: Freight.ClearanceView;
|
||
stats: { pct: number; approved: number; total: number };
|
||
}) {
|
||
const direction = booking?.tradeDirection ?? "—";
|
||
const origin =
|
||
booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin";
|
||
const destination =
|
||
booking?.destinationYard?.label ??
|
||
booking?.destinationYard?.code ??
|
||
"Destination";
|
||
|
||
return (
|
||
<Paper withBorder radius="md" p="lg">
|
||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||
<ShieldCheck size={26} />
|
||
</ThemeIcon>
|
||
<Box style={{ minWidth: 0 }}>
|
||
<Group gap={8} wrap="nowrap">
|
||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||
{booking?.reference ?? "Clearance"}
|
||
</Text>
|
||
<Badge
|
||
size="sm"
|
||
variant="light"
|
||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||
radius="sm"
|
||
>
|
||
{direction}
|
||
</Badge>
|
||
{clearance.includesCustoms ? (
|
||
<Badge
|
||
size="sm"
|
||
variant="light"
|
||
color="edr-green"
|
||
radius="sm"
|
||
leftSection={<ShieldCheck size={12} />}
|
||
>
|
||
Customs
|
||
</Badge>
|
||
) : null}
|
||
</Group>
|
||
<Group gap={8} mt={6} wrap="nowrap">
|
||
<Text size="sm" fw={600} truncate maw={160}>
|
||
{origin}
|
||
</Text>
|
||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||
<Text size="sm" fw={600} truncate maw={160}>
|
||
{destination}
|
||
</Text>
|
||
</Group>
|
||
</Box>
|
||
</Group>
|
||
|
||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||
<Group justify="space-between" mb={6}>
|
||
<Text size="xs" c="dimmed" fw={600}>
|
||
Document review
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{stats.approved}/{stats.total}
|
||
</Text>
|
||
</Group>
|
||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||
</Box>
|
||
</Group>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
function ProgressStat({
|
||
color,
|
||
label,
|
||
value,
|
||
}: {
|
||
color: string;
|
||
label: string;
|
||
value: number;
|
||
}) {
|
||
return (
|
||
<Stack gap={2} align="center">
|
||
<Text fw={700} fz={18} c="edr-text">
|
||
{value}
|
||
</Text>
|
||
<Group gap={4} wrap="nowrap">
|
||
<Box
|
||
style={{
|
||
width: 7,
|
||
height: 7,
|
||
borderRadius: 999,
|
||
background: `var(--mantine-color-${color}-6)`,
|
||
}}
|
||
/>
|
||
<Text fz="11px" c="dimmed">
|
||
{label}
|
||
</Text>
|
||
</Group>
|
||
</Stack>
|
||
);
|
||
}
|