Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx
Marshal 835c9e111c feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system.
- Introduced  API endpoint and service method to handle container movement.
- Updated  component to support drag-and-drop for rearranging containers.
- Enhanced  to allow moving containers to other wagons via a context menu.
- Implemented UI feedback for container movement actions, including loading states and success/error notifications.
- Updated relevant types and constants to accommodate new container movement logic.
- Added tests for the rule engine to ensure proper handling of hazardous bookings.
2026-07-21 23:02:06 +00:00

355 lines
13 KiB
TypeScript

import { directionLabel } from "@/lib/utils";
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,
AlertTriangle,
ClipboardList,
FileText,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
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 { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
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;
booking: BookingDetail;
};
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
try {
// Probe the contract endpoints first; a booking-id row 404s here by design
// and falls back to the booking lookup below. Suppress the global error
// modal so that expected 404 never surfaces to the user.
const [clearance, contract] = await Promise.all([
contractsService.getClearance(id, { suppressErrorModal: true }),
contractsService.getById(id, { suppressErrorModal: true }),
]);
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,
booking,
};
}
}
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
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);
// react-query's imperative refetch() ignores `enabled`, so calling it while
// linkedBookingId is still undefined (pre-booking clearance) would fire
// GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it.
const refetchBookingMilestonesIfLinked = () => {
if (linkedBookingId) void refetchBookingMilestones();
};
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;
// Incident reporting attaches to a booking; a contract-level clearance can
// only report against its linked booking once one exists.
const incidentBookingId = data.kind === "booking" ? id : linkedBookingId;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
const shipmentBooking = data.kind === "booking" ? data.booking : null;
const bookingCompleted = Number(shipmentBooking?.totalAmount ?? 0) > 0;
// Import boundary (DO collected) / export boundary (release) reached →
// clearance is ready and GL creates the real booking. Show the create-booking
// CTA here so the GL user who finishes the DJ step isn't left without a next
// action. Permission-gated so only booking creators (GL Ethiopia) see it.
const canCompleteBooking =
shipmentBooking?.status === "CLEARANCE_READY" &&
Boolean(shipmentBooking?.contractId) &&
!bookingCompleted &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
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">
{directionLabel(data.tradeDirection)}
</Badge>
}
action={
<Group gap="sm">
{isImport ? (
<Button
variant={canCompleteBooking ? "default" : "filled"}
color="edr-green"
leftSection={<Upload size={16} />}
disabled={!canUploadDo}
onClick={() => setUploadKind("do")}
>
{hasDo ? "Replace DO" : "Upload DO"}
</Button>
) : (
<Button
variant={canCompleteBooking ? "default" : "filled"}
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>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
) : null}
</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}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: 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();
refetchBookingMilestonesIfLinked();
}}
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>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Log container or seal issues discovered during clearance handling.
</Text>
<IncidentReportCard bookingId={incidentBookingId} />
</Stack>
</SectionCard>
</Tabs.Panel>
) : null}
</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>
);
}