finilize gl flow for export

This commit is contained in:
Marshal
2026-07-02 17:55:59 +00:00
parent f77e4d150b
commit dd47068a4b
24 changed files with 2462 additions and 598 deletions

View File

@@ -3,7 +3,6 @@ import {
Alert,
Badge,
Button,
FileInput,
Group,
NumberInput,
Paper,
@@ -19,7 +18,6 @@ import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -35,6 +33,7 @@ import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
findWorkflowFile,
@@ -47,7 +46,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type RoleMode = "ET" | "DJ" | "ALL";
type ClearanceViewLike = Pick<
export type ClearanceViewLike = Pick<
Freight.ContractClearanceView,
| "nextAction"
| "dutyRequired"
@@ -60,11 +59,20 @@ type ClearanceViewLike = Pick<
| "exportClearanceFinalized"
| "allApproved"
| "t1"
| "train"
| "gatepassGranted"
| "gatepassAt"
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "finalInvoice"
| "vesselDepartureDate"
| "linkedBookingId"
> & { operationReady?: boolean };
type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
function isMilestoneDone(
export function isMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
@@ -72,7 +80,7 @@ function isMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
function isBookingMilestoneDone(
export function isBookingMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
@@ -524,91 +532,24 @@ export function PhasedClearanceActionPanel({
}
return (
<Stack gap="md">
{clearance.roHold && clearance.roHoldReason ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
{clearance.roHoldReason}
</Alert>
) : null}
{showDj && canDj ? (
useUploadModals ? (
<ReleaseOrderActions
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
workflowFiles={workflowFiles}
onUploadRoRequest={onUploadRoRequest}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<ReleaseOrderCard
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
onChanged={onChanged}
/>
)
) : null}
{showEt && canEt ? (
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
{showEt && canEt ? (
<ExportReleaseCard
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
onChanged={onChanged}
/>
) : null}
{(clearance.bookingReady || clearance.operationReady) &&
bookingCreateHref &&
!bookingCreated &&
showEt &&
canEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<Text size="sm" c="dimmed" mb="sm">
Pre-booking clearance is complete. Create the shipment booking for the customer.
</Text>
<Button component="a" href={bookingCreateHref} color="edr-green">
Create shipment booking
</Button>
</SectionCard>
) : bookingCreated && showEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<StepStatus
done
pendingLabel=""
doneLabel="Shipment booking has been created for this contract."
/>
</SectionCard>
) : null}
{bookingCreated && showEt && canEt && bookingId ? (
<ExportPostBookingSection
contractId={contractId}
bookingId={bookingId}
clearance={clearance}
bookingMilestones={bookingMilestones}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
</Stack>
<ExportClearanceStepper
contractId={contractId}
bookingId={bookingId}
clearance={clearance}
workflowFiles={workflowFiles}
showEt={showEt}
canEt={canEt}
showDj={showDj}
canDj={canDj}
onChanged={onChanged}
bookingCreateHref={bookingCreateHref}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
useUploadModals={useUploadModals}
onUploadRoRequest={onUploadRoRequest}
bookingCreated={bookingCreated}
bookingMilestones={bookingMilestones}
/>
);
}
@@ -776,7 +717,7 @@ function ImportT1Section({
);
}
function StepStatus({
export function StepStatus({
done,
pendingLabel,
doneLabel,
@@ -805,7 +746,7 @@ function StepStatus({
);
}
function DeclarationStep({
export function DeclarationStep({
entityId,
isBooking,
onChanged,
@@ -1171,411 +1112,3 @@ function DeliveryOrderStep({
);
}
function ReleaseOrderActions({
entityId,
isBooking,
clearance,
workflowFiles = [],
onUploadRoRequest,
onChanged,
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
workflowFiles?: Freight.ClearanceWorkflowFile[];
onUploadRoRequest?: () => void;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [amendLoading, setAmendLoading] = useState(false);
const roFile = findWorkflowFile(workflowFiles, "release_order");
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Release Order
</Text>
<Stack gap="sm">
{roFile ? (
<PhasedUploadedFileRow
label="Release Order"
file={roFile}
onView={onViewFile}
onDownload={onDownloadFile}
/>
) : null}
{clearance.vesselDepartureDate ? (
<Text size="sm" c="dimmed">
Vessel departure:{" "}
{new Date(clearance.vesselDepartureDate).toLocaleDateString()}
</Text>
) : null}
<Group>
{onUploadRoRequest ? (
<Button color="edr-green" leftSection={<Upload size={16} />} onClick={onUploadRoRequest}>
{roFile ? "Replace RO" : "Upload RO"}
</Button>
) : null}
<Button
variant="light"
color="orange"
loading={amendLoading}
onClick={async () => {
setAmendLoading(true);
try {
if (isBooking) {
await bookingsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
} else {
await contractsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
}
toast.success("Amendment request recorded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setAmendLoading(false);
}
}}
>
Request amendment
</Button>
</Group>
</Stack>
</Paper>
);
}
function ReleaseOrderCard({
entityId,
isBooking,
clearance,
onChanged,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [vesselDate, setVesselDate] = useState<Date | null>(
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
const [amendLoading, setAmendLoading] = useState(false);
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Release Order
</Text>
<Stack gap="sm">
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
<DateInput
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
/>
<Group>
<Button
color="edr-green"
loading={loading}
disabled={!file || !vesselDate}
onClick={async () => {
if (!file || !vesselDate) return;
setLoading(true);
try {
const iso = vesselDate.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success("Release Order accepted");
}
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload RO
</Button>
<Button
variant="light"
color="orange"
loading={amendLoading}
onClick={async () => {
setAmendLoading(true);
try {
if (isBooking) {
await bookingsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
} else {
await contractsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
}
toast.success("Amendment request recorded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setAmendLoading(false);
}
}}
>
Request amendment
</Button>
</Group>
</Stack>
</Paper>
);
}
function ExportReleaseCard({
entityId,
isBooking,
clearance,
onChanged,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
const done = clearance.bookingReady || clearance.operationReady;
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Export release
</Text>
<Button
color="edr-green"
loading={loading}
disabled={Boolean(done)}
onClick={async () => {
setLoading(true);
try {
if (isBooking) {
await bookingsService.confirmExportRelease(entityId);
} else {
await contractsService.confirmExportRelease(entityId);
}
toast.success("Export released — ready for booking");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Confirm export release
</Button>
</Paper>
);
}
function ExportPostBookingSection({
contractId,
bookingId,
clearance,
bookingMilestones,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
contractId?: string;
bookingId: string;
clearance: ClearanceViewLike;
bookingMilestones: MilestoneRow[];
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const wagonAllocated = isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED");
const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED");
const transitUploaded = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const finalized = Boolean(clearance.exportClearanceFinalized);
return (
<SectionCard icon={Truck} title="Post-booking clearance" accent="edr-green">
<Stack gap="md">
<Text size="sm" c="dimmed">
After the customer pays and operations allocates wagons, upload the transit
permit and finalize export clearance.
</Text>
{!paymentSettled ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Waiting for the customer to pay freight charges.
</Alert>
) : null}
{paymentSettled && !wagonAllocated ? (
<Alert color="yellow" variant="light" icon={<Clock size={16} />}>
Waiting for operations to allocate wagons before the transit permit can be
uploaded.
</Alert>
) : null}
{wagonAllocated && !transitUploaded ? (
<ExportTransitPermitStep
bookingId={bookingId}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
{transitUploaded ? (
<StepStatus
done
pendingLabel=""
doneLabel="Transit permit uploaded."
/>
) : null}
{transitUploaded && !finalized && contractId ? (
<FinalizeExportClearanceStep contractId={contractId} onChanged={onChanged} />
) : null}
{finalized ? (
<StepStatus
done
pendingLabel=""
doneLabel="Export clearance finalized."
/>
) : null}
</Stack>
</SectionCard>
);
}
function exportTransitFilesFromWorkflow(
workflowFiles: Freight.ClearanceWorkflowFile[],
): TransitPermitUploadedRow[] {
return workflowFiles
.filter((f) => f.category === "transit" && f.file)
.map((f) => ({
code: f.code,
label: f.label,
file: f.file!,
}));
}
function ExportTransitPermitStep({
bookingId,
workflowFiles = [],
replaceMode = false,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const uploaded = exportTransitFilesFromWorkflow(workflowFiles);
const hasUploaded = uploaded.length > 0;
if (hasUploaded && !replaceMode) {
return (
<Stack gap="sm">
<Text size="sm" fw={700}>
Transit Permit
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
);
}
return (
<TransitPermitMultiUpload
replaceMode={replaceMode || hasUploaded}
uploaded={uploaded}
fileFieldPrefix="export_transport_document"
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async (payload) => {
try {
await contractsService.uploadTransportDocument(bookingId, payload);
toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
throw e;
}
}}
/>
);
}
function FinalizeExportClearanceStep({
contractId,
onChanged,
}: {
contractId: string;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
Confirm that export clearance is complete now that the transit permit is on file.
</Text>
<Button
color="edr-green"
loading={loading}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);
try {
await contractsService.finalizeExportClearance(contractId);
toast.success("Export clearance finalized");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Finalize clearance
</Button>
</Stack>
);
}

View File

@@ -60,6 +60,7 @@ export const QUERY_KEYS = {
["contracts", "clearance-queue", region ?? "ET"] as const,
clearanceHistory: (region?: string) =>
["contracts", "clearance-history", region ?? "ET"] as const,
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingMilestones: (bookingId: string) =>

View File

@@ -215,6 +215,15 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/t1-documents`,
BOOKING_T1_CLOSE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/t1-close`,
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
BOOKING_GATEPASS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/gatepass`,
BOOKING_FINAL_INVOICE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice`,
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},

View File

@@ -68,6 +68,15 @@ export function useDjClearanceQueue(enabled = true) {
});
}
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
export function useDjClearanceSchedules(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
queryFn: () => contractsService.getDjClearanceSchedules(),
enabled,
});
}
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({

View File

@@ -29,6 +29,7 @@ import {
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";
@@ -85,6 +86,11 @@ export default function GlClearanceDetailPage() {
enabled: Boolean(id),
});
const linkedBookingId =
data?.kind === "contract" ? (data.clearance.linkedBookingId ?? undefined) : undefined;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
if (isLoading) {
return (
<PageContainer>
@@ -197,7 +203,13 @@ export default function GlClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? 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}
@@ -205,7 +217,10 @@ export default function GlClearanceDetailPage() {
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => void refetch()}
onChanged={() => {
void refetch();
void refetchBookingMilestones();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>

View File

@@ -1,30 +1,172 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
import { ChevronRight, Container, Ship } from "lucide-react";
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Tabs,
Text,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import {
useDjClearanceQueue,
useDjClearanceSchedules,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
const schedulesQuery = useDjClearanceSchedules();
const contractItems = contractQueue?.items ?? [];
const bookingItems = bookingQueue ?? [];
const scheduleItems = schedulesQuery.data ?? [];
const [gatepassTarget, setGatepassTarget] =
useState<Freight.DjClearanceSchedule | null>(null);
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
const [granting, setGranting] = useState(false);
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
() => [
{
header: "Train",
accessorKey: "trainNumber",
cell: ({ row }) => (
<Text size="sm" fw={700}>
{row.original.trainNumber ?? "—"}
</Text>
),
},
{
header: "Route",
id: "route",
cell: ({ row }) => (
<Text size="sm">
{row.original.origin ?? "—"} {row.original.destination ?? "—"}
</Text>
),
},
{
header: "Scheduled departure",
id: "scheduled",
cell: ({ row }) => (
<Text size="sm">
{row.original.scheduledDepartureDate
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
: "—"}
</Text>
),
},
{
header: "Departed",
id: "departed",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualDepartureAt
? new Date(row.original.actualDepartureAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Arrived",
id: "arrived",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualArrivalAt
? new Date(row.original.actualArrivalAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Status",
accessorKey: "status",
cell: ({ row }) => (
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
{row.original.status}
</Badge>
),
},
{
header: "Customs bookings",
id: "customs",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green" radius="sm">
{bookings.length}
</Badge>
{directions.map((d) => (
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
{d}
</Badge>
))}
</Group>
);
},
},
{
header: "Gate pass",
id: "gatepass",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const allGranted =
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
if (allGranted) {
return (
<Badge variant="light" color="edr-green" radius="sm">
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
</Badge>
);
}
return (
<Button
size="xs"
color="edr-green"
leftSection={<Truck size={14} />}
onClick={(e) => {
e.stopPropagation();
setGatepassAt(new Date());
setGatepassTarget(row.original);
}}
>
Gate pass
</Button>
);
},
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
/>
<Tabs defaultValue="contracts" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
Schedules ({scheduleItems.length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="contracts">
@@ -36,8 +178,7 @@ export default function GlDjiboutiClearanceListPage() {
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
pre-clearance is finalized.
No Djibouti customs contracts yet.
</Text>
) : (
contractItems.map((c) => (
@@ -73,51 +214,113 @@ export default function GlDjiboutiClearanceListPage() {
)}
</Tabs.Panel>
<Tabs.Panel value="bookings">
{bookingsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs bookings yet.
</Text>
) : (
bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Container size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {b.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Booking
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
<Tabs.Panel value="schedules">
<DataTable
columns={columns}
data={scheduleItems}
status={
schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success"
}
error={
schedulesQuery.isError
? {
message: "Failed to load train schedules.",
onRetry: () => void schedulesQuery.refetch(),
}
: undefined
}
emptyMessage="No train schedules carry customs bookings yet."
/>
</Tabs.Panel>
</Tabs>
<Modal
opened={gatepassTarget != null}
onClose={() => setGatepassTarget(null)}
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>
Gate pass train {gatepassTarget?.trainNumber ?? ""}
</Text>
</Group>
}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Grants the gate pass for all{" "}
{gatepassTarget?.customsBookings.length ?? 0} customs booking
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
train.
</Text>
<DateTimePicker
label="Gate pass time"
value={gatepassAt}
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => setGatepassTarget(null)}
disabled={granting}
>
Cancel
</Button>
<Button
color="edr-green"
loading={granting}
leftSection={<Truck size={16} />}
onClick={async () => {
if (!gatepassTarget) return;
setGranting(true);
try {
const result = await contractsService.grantScheduleGatepass(
gatepassTarget.id,
(gatepassAt ?? new Date()).toISOString(),
);
if (result.skipped.length > 0) {
toast.error(
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
);
} else {
toast.success(
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
);
}
setGatepassTarget(null);
void schedulesQuery.refetch();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setGranting(false);
}
}}
>
Grant gate pass
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
function statusColor(status: string): string {
switch (status) {
case "SCHEDULED":
return "blue";
case "DISPATCHED":
return "yellow";
case "ARRIVED":
return "edr-green";
default:
return "gray";
}
}

View File

@@ -354,6 +354,59 @@ export const contractsService = {
return unwrap(response.data) as Freight.ClearanceT1State;
},
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
return unwrap(response.data) as Freight.DjClearanceSchedule[];
},
/** Gate pass for every customs booking on a train schedule (captures time). */
grantScheduleGatepass: async (
scheduleId: string,
gatepassAt?: string,
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
gatepassAt,
});
return unwrap(response.data) as {
granted: number;
skipped: Array<{ bookingId: string; error: string }>;
};
},
/** Gate pass for a single customs booking (captures time). */
grantGatepass: async (
bookingId: string,
gatepassAt?: string,
): Promise<{ bookingId: string; gatepassAt: string }> => {
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
},
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
sendFinalInvoice: async (
bookingId: string,
payload: { amount: number; currency: string; description?: string; file: File },
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
const form = new FormData();
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
if (payload.description) form.append("description", payload.description);
form.append("file", payload.file);
const response = await client.post(C.BOOKING_FINAL_INVOICE(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
},
/** GL (ET or DJ) confirms the payment slip — settles the final invoice. */
confirmFinalInvoicePaid: async (
bookingId: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
const response = await client.post(C.BOOKING_FINAL_INVOICE_CONFIRM(bookingId));
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(