Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx

284 lines
9.4 KiB
TypeScript

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 { useBookingMilestones } from "@/hooks/contracts/useContracts";
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),
});
const linkedBookingId =
data?.kind === "contract" ? (data.clearance.linkedBookingId ?? undefined) : undefined;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
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"));
// DO upload is un-gated — Djibouti GL may attach it at any point, any file type.
const canUploadDo = isImport;
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>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection
bookingId={id!}
hideSummary
readOnly
phasedCustoms
/>
) : (
<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 : linkedBookingId}
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
roleMode="DJ"
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => {
void refetch();
void refetchBookingMilestones();
}}
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>
);
}