mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
- 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.
355 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|