Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
2026-07-01 20:55:17 +03:00

442 lines
14 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 { 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,
ClipboardList,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
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";
}
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,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(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 = contract?.reference ?? "Clearance";
const phasedCustoms =
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
) ?? false;
const ready = clearance?.bookingReady === true;
const docReviewLocked = phasedCustoms
? docsPhaseComplete
: clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
const shipmentLocked = Boolean(
contract?.status &&
[
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
"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;
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 couldnt load this contracts clearance.
</Alert>
</PageContainer>
);
}
const workflowFiles = clearance.workflowFiles ?? [];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference },
]}
meta={
ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready customer books
</Badge>
) : 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 contract={contract} stats={stats} />
{ready ? (
<Alert
color="edr-green"
radius="md"
icon={<PackageCheck size={16} />}
title="Clearance complete"
>
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
</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>
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly
phasedCustoms={phasedCustoms}
/>
</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 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>
</Stack>
{viewer}
</PageContainer>
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.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>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<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>
);
}