Files
edr-platform/apps/edr-freight-web/portal/src/pages/transit-agent/TransitDocumentsPanel.tsx

1737 lines
51 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 {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Clock3,
Download,
Eye,
FileCheck2,
FileStack,
FileText,
Lock,
MapPin,
PackageCheck,
Ship,
ShieldCheck,
Timer,
Train,
Trash2,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import {
isDeliveryOrderFileCode,
isDjiboutiT1FileCode,
isGatePassFileCode,
isReleaseOrderFileCode,
isT1TransportFileCode,
} from "@edr/types";
import { isViewable } from "@edr/ui-common";
import {
PortalMultiFileDropzone,
formatBytes,
} from "@/components/contracts/PortalMultiFileDropzone";
import { BORDER, GREEN, INK, MUTED } from "@/pages/contracts/contract-ui";
import {
downloadStoredFile,
fetchViewableFile,
} from "@/services/files.service";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
// ── Time helpers ─────────────────────────────────────────────────────────────
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** "Sep 2, 2026, 14:05" — the officer reads these against a clock, not a calendar. */
function formatStamp(value?: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/** Signed duration in ms → "2d 4h 13m", "45m", "<1m". */
function formatDuration(ms: number): string {
const abs = Math.abs(ms);
if (abs < MINUTE) return "<1m";
const days = Math.floor(abs / DAY);
const hours = Math.floor((abs % DAY) / HOUR);
const minutes = Math.floor((abs % HOUR) / MINUTE);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes || parts.length === 0) parts.push(`${minutes}m`);
return parts.join(" ");
}
/** Elapsed between two stamps; null when either is missing. */
function elapsed(from?: string | null, to?: string | null): string | null {
if (!from || !to) return null;
return formatDuration(new Date(to).getTime() - new Date(from).getTime());
}
/**
* How far an upload sits from a train event. "after" is the normal case; a
* document filed BEFORE the event (paperwork prepared in advance) is labeled
* so, never shown as a negative number.
*/
function offsetFrom(
base: string | null | undefined,
at: string | null | undefined,
event: string,
): { text: string; late: boolean } | null {
if (!base || !at) return null;
const diff = new Date(at).getTime() - new Date(base).getTime();
return diff >= 0
? { text: `${formatDuration(diff)} after ${event}`, late: false }
: { text: `${formatDuration(diff)} before ${event}`, late: true };
}
const latestOf = (stamps: Array<string | null | undefined>): string | null =>
stamps.reduce<string | null>(
(max, s) => (s && (!max || s > max) ? s : max),
null,
);
const earliestOf = (stamps: Array<string | null | undefined>): string | null =>
stamps.reduce<string | null>(
(min, s) => (s && (!min || s < min) ? s : min),
null,
);
/** Newest history event for an action, or null. History is newest-first. */
function latestEvent(
history: Freight.ClearanceHistoryEvent[],
action: string,
): Freight.ClearanceHistoryEvent | null {
return history.find((e) => e.action === action) ?? null;
}
// ── Small presentational pieces ──────────────────────────────────────────────
/** One figure in a stat strip: label, big value, optional footnote. */
function Stat({
icon: Icon,
label,
value,
hint,
tone = "default",
}: {
icon: typeof Clock3;
label: string;
value: React.ReactNode;
hint?: React.ReactNode;
tone?: "default" | "green" | "blue" | "orange" | "muted";
}) {
const color =
tone === "green"
? "edr-green"
: tone === "blue"
? "blue"
: tone === "orange"
? "orange"
: "gray";
return (
<Paper
radius="md"
p="sm"
style={{
border: `1px solid ${BORDER}`,
background: tone === "muted" ? "#FAFCFE" : "#fff",
minWidth: 0,
}}
>
<Group gap={8} wrap="nowrap" mb={6}>
<ThemeIcon variant="light" color={color} radius="md" size={26}>
<Icon size={14} />
</ThemeIcon>
<Text fz={11} fw={700} c="dimmed" tt="uppercase" lts="0.3px" truncate>
{label}
</Text>
</Group>
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{value}
</Text>
{hint ? (
<Text fz={11.5} c="dimmed" mt={2} truncate>
{hint}
</Text>
) : null}
</Paper>
);
}
function OffsetChip({
offset,
color,
}: {
offset: { text: string; late: boolean } | null;
color: string;
}) {
if (!offset) return null;
return (
<Badge
size="xs"
variant="light"
radius="sm"
tt="none"
color={offset.late ? "gray" : color}
leftSection={<Timer size={10} />}
>
{offset.text}
</Badge>
);
}
/** A stored document row: name, stamp, train offsets, view / download / remove. */
function StoredDocumentRow({
item,
train,
onView,
onRemove,
removing,
}: {
item: Freight.ClearanceWorkflowFile;
train?: Freight.ClearanceTrainState | null;
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onRemove?: (file: { id: string; name: string }) => void;
removing?: boolean;
}) {
const file = item.file;
if (!file) return null;
const canPreview = isViewable({ name: file.name, url: "" });
const uploadedAt = file.uploadedAt ?? null;
return (
<Paper radius="md" p="sm" style={{ border: `1px solid ${BORDER}` }}>
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={38}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate style={{ color: INK }}>
{item.label}
</Text>
{file.size ? (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
· {formatBytes(file.size)}
</Text>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
<Group gap={6} mt={6} wrap="wrap">
<Badge
size="xs"
variant="outline"
color="gray"
radius="sm"
tt="none"
leftSection={<Clock3 size={10} />}
>
{formatStamp(uploadedAt)}
</Badge>
{train ? (
<>
<OffsetChip
offset={offsetFrom(train.departedAt, uploadedAt, "departure")}
color="blue"
/>
<OffsetChip
offset={offsetFrom(train.arrivedAt, uploadedAt, "arrival")}
color="edr-green"
/>
</>
) : null}
</Group>
</Box>
</Group>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{canPreview ? (
<Tooltip label="Preview" withArrow>
<ActionIcon
variant="default"
radius="md"
aria-label="Preview"
onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
>
<Eye size={15} />
</ActionIcon>
</Tooltip>
) : null}
<Tooltip label="Download" withArrow>
<ActionIcon
variant="default"
radius="md"
aria-label="Download"
onClick={() => void downloadStoredFile(file.id, file.name)}
>
<Download size={15} />
</ActionIcon>
</Tooltip>
{onRemove ? (
<Tooltip label="Remove" withArrow>
<ActionIcon
variant="subtle"
color="red"
radius="md"
aria-label="Remove"
loading={removing}
onClick={() => onRemove({ id: file.id, name: file.name })}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}
function EmptyDocs({ icon: Icon, children }: { icon: typeof FileText; children: React.ReactNode }) {
return (
<Box
py="lg"
px="md"
style={{
borderRadius: 12,
border: `1px dashed ${BORDER}`,
background: "#FAFCFE",
textAlign: "center",
}}
>
<Stack gap={6} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
<Icon size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={360}>
{children}
</Text>
</Stack>
</Box>
);
}
/** Card chrome shared by the three document sections. */
function DocumentCard({
icon: Icon,
title,
subtitle,
status,
action,
children,
}: {
icon: typeof FileText;
title: string;
subtitle: string;
status?: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card withBorder radius="md" p={0} style={{ overflow: "hidden", height: "100%" }}>
<Group
justify="space-between"
wrap="nowrap"
align="flex-start"
px={18}
py={14}
gap="md"
style={{ borderBottom: `1px solid ${BORDER}` }}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<Icon size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={700} fz={14.5} style={{ color: INK }}>
{title}
</Text>
{status}
</Group>
<Text fz={11.5} c="dimmed">
{subtitle}
</Text>
</Box>
</Group>
{action ? <Box style={{ flexShrink: 0 }}>{action}</Box> : null}
</Group>
<Box p="md">{children}</Box>
</Card>
);
}
// ── Train timeline strip ─────────────────────────────────────────────────────
function TrainStrip({ train }: { train?: Freight.ClearanceTrainState | null }) {
const departed = train?.departedAt ?? null;
const arrived = train?.arrivedAt ?? null;
const transit = elapsed(departed, arrived);
const sinceDeparture =
departed && !arrived ? formatDuration(Date.now() - new Date(departed).getTime()) : null;
const sinceArrival = arrived
? formatDuration(Date.now() - new Date(arrived).getTime())
: null;
return (
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Stat
icon={Train}
label="Train departed"
value={departed ? formatStamp(departed) : "Not departed"}
hint={
departed
? "Loaded onto the train"
: train?.wagonAllocated
? "Wagons allocated — awaiting departure"
: "Awaiting wagon allocation"
}
tone={departed ? "blue" : "muted"}
/>
<Stat
icon={MapPin}
label="Arrived at Djibouti"
value={arrived ? formatStamp(arrived) : departed ? "In transit" : "—"}
hint={
arrived
? `${sinceArrival} ago`
: sinceDeparture
? `${sinceDeparture} since departure`
: "Arrival is recorded by operations"
}
tone={arrived ? "green" : "muted"}
/>
<Stat
icon={Timer}
label="Transit time"
value={transit ?? (sinceDeparture ? `${sinceDeparture} so far` : "—")}
hint={transit ? "Departure → arrival" : "Measured once the train arrives"}
tone={transit ? "green" : "muted"}
/>
</SimpleGrid>
);
}
// ── Release Order card ───────────────────────────────────────────────────────
function ReleaseOrderCard({
bookingId,
clearance,
history,
onView,
onChanged,
}: {
bookingId: string;
clearance: Freight.ClearanceView;
history: Freight.ClearanceHistoryEvent[];
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const roFiles = (clearance.workflowFiles ?? []).filter(
(f) => isReleaseOrderFileCode(f.code) && f.file,
);
const hasRo = roFiles.length > 0;
// The customs declaration is the gate: the API refuses an RO before it.
const declaredMilestone = clearance.milestones?.find(
(m) => m.milestoneCode === "DECLARED",
);
const declared =
declaredMilestone?.status === "COMPLETED" ||
declaredMilestone?.status === "SKIPPED";
const declaredAt =
declaredMilestone?.triggeredAt ??
latestEvent(history, "DECLARATION_UPLOADED")?.at ??
null;
// "Uploaded" is the latest stamp on the current RO set — replacing the RO
// stores a fresh batch, so this is always the last update, as required.
const roEvents = history.filter((e) => e.action === "RELEASE_ORDER_UPLOADED");
const roAt =
latestOf(roFiles.map((f) => f.file?.uploadedAt)) ?? roEvents[0]?.at ?? null;
const roUpdated = roEvents.length > 1;
const declarationToRo = elapsed(declaredAt, roAt);
const secured =
clearance.milestones?.find((m) => m.milestoneCode === "RELEASE_ORDER_SECURED")
?.status === "COMPLETED";
const hold = clearance.roHoldReason ?? null;
const status = hold ? (
<Badge size="xs" color="red" variant="light" radius="sm" tt="none">
On hold
</Badge>
) : secured ? (
<Badge
size="xs"
color="edr-green"
variant="light"
radius="sm"
tt="none"
leftSection={<CheckCircle2 size={10} />}
>
Secured
</Badge>
) : hasRo ? (
<Badge size="xs" color="orange" variant="light" radius="sm" tt="none">
Uploaded
</Badge>
) : declared ? (
<Badge size="xs" color="blue" variant="light" radius="sm" tt="none">
Ready to upload
</Badge>
) : (
<Badge
size="xs"
color="gray"
variant="light"
radius="sm"
tt="none"
leftSection={<Lock size={10} />}
>
Waiting for declaration
</Badge>
);
return (
<>
<DocumentCard
icon={Ship}
title="Release Order"
subtitle="Filed with the vessel departure date once GL Ethiopia uploads the customs declaration"
status={status}
action={
<Tooltip
label="The customs declaration has not been uploaded yet"
disabled={declared}
withArrow
>
<Button
color="edr-green"
radius="md"
size="sm"
leftSection={hasRo ? <FileCheck2 size={15} /> : <Upload size={15} />}
disabled={!declared}
onClick={() => setOpen(true)}
>
{hasRo ? "Replace Release Order" : "Upload Release Order"}
</Button>
</Tooltip>
}
>
<Stack gap="md">
{hold ? (
<Alert
color="red"
variant="light"
icon={<AlertTriangle size={16} />}
title="RO amendment hold"
>
{hold}
</Alert>
) : null}
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Stat
icon={FileText}
label="Declaration uploaded"
value={declaredAt ? formatStamp(declaredAt) : declared ? "Done" : "Pending"}
hint={declared ? "By GL Ethiopia — RO unlocked" : "Unlocks the Release Order"}
tone={declared ? "green" : "muted"}
/>
<Stat
icon={Ship}
label={roUpdated ? "RO last updated" : "RO uploaded"}
value={roAt ? formatStamp(roAt) : "Not yet"}
hint={
roAt
? clearance.vesselDepartureDate
? `Vessel departs ${clearance.vesselDepartureDate}`
: roUpdated
? `Replaced ${roEvents.length - 1}×`
: "First upload"
: "Upload to record the time"
}
tone={roAt ? "green" : "muted"}
/>
<Stat
icon={Timer}
label="Declaration → RO"
value={declarationToRo ?? "—"}
hint={
declarationToRo
? roUpdated
? "Measured to the last update"
: "Time to secure the RO"
: "Measured once the RO is uploaded"
}
tone={declarationToRo ? "blue" : "muted"}
/>
</SimpleGrid>
{hasRo ? (
<Stack gap={8}>
{roFiles.map((item) => (
<StoredDocumentRow key={item.code} item={item} onView={onView} />
))}
</Stack>
) : (
<EmptyDocs icon={Ship}>
{declared
? "No Release Order on file yet. Upload the RO and confirm the vessel departure date."
: "The Release Order can be uploaded as soon as the customs declaration is on file."}
</EmptyDocs>
)}
</Stack>
</DocumentCard>
<ReleaseOrderModal
opened={open}
bookingId={bookingId}
replaceMode={hasRo}
vesselDepartureDate={clearance.vesselDepartureDate ?? null}
onClose={() => setOpen(false)}
onSuccess={onChanged}
/>
</>
);
}
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
function toIsoDate(value: Date | null): string | null {
if (!value) return null;
const tz = value.getTimezoneOffset() * 60000;
return new Date(value.getTime() - tz).toISOString().slice(0, 10);
}
function ReleaseOrderModal({
opened,
bookingId,
replaceMode,
vesselDepartureDate,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
replaceMode: boolean;
vesselDepartureDate: string | null;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.uploadReleaseOrder(
bookingId,
files,
toIsoDate(vesselDate)!,
),
onSuccess: (result) => {
// The API answers with a hold instead of an error when the vessel date is
// too soon — say so rather than reporting a clean success.
if (result?.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
}
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="lg"
title={
<Group gap={8}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={30}>
<Ship size={16} />
</ThemeIcon>
<Text fw={700}>
{replaceMode ? "Replace Release Order" : "Upload Release Order"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Upload the Release Order and confirm the vessel departure date. The
upload time is recorded and measured against the customs declaration.
{replaceMode
? " Replacing removes the current RO files and records a new time."
: ""}
</Text>
<DateInput
label="Vessel departure date"
placeholder="Select date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={today}
size="sm"
radius="md"
required
withAsterisk
/>
<PortalMultiFileDropzone
label="Release Order files"
description="Scanned RO pages or the port's PDF. Every file in this batch replaces what is on file."
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={submit.isPending}
disabled={files.length === 0 || !vesselDate}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
{replaceMode ? "Replace RO" : "Upload RO"}
{files.length > 1 ? ` (${files.length} files)` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
// ── Gate pass / Djibouti T1 cards ────────────────────────────────────────────
type ArrivalKind = "gate_pass" | "djibouti_t1";
const ARRIVAL_SETS: Record<
ArrivalKind,
{
title: string;
subtitle: string;
icon: typeof FileText;
matches: (code: string) => boolean;
upload: (bookingId: string, files: File[]) => Promise<{ uploaded: number }>;
empty: string;
modalHint: string;
}
> = {
gate_pass: {
title: "Gate pass",
subtitle: "Port gate pass documents collected at Djibouti",
icon: ShieldCheck,
matches: isGatePassFileCode,
upload: transitAssignmentsService.uploadGatePassDocuments,
empty:
"No gate pass documents yet. Add each pass as you collect it — every upload is time-stamped.",
modalHint: "Gate pass scans or photos. You can add more later.",
},
djibouti_t1: {
title: "Djibouti T1",
subtitle: "T1 transit documents issued at Djibouti customs",
icon: FileStack,
matches: isDjiboutiT1FileCode,
upload: transitAssignmentsService.uploadDjiboutiT1Documents,
empty:
"No Djibouti T1 documents yet. Add each T1 as customs issues it — every upload is time-stamped.",
modalHint: "T1 scans or photos. You can add more later.",
},
};
function ArrivalDocumentsCard({
kind,
bookingId,
clearance,
onView,
onChanged,
}: {
kind: ArrivalKind;
bookingId: string;
clearance: Freight.ClearanceView;
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const set = ARRIVAL_SETS[kind];
const [open, setOpen] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null);
const items = (clearance.workflowFiles ?? []).filter(
(f) => set.matches(f.code) && f.file,
);
const train = clearance.train ?? null;
const stamps = items.map((i) => i.file?.uploadedAt);
const firstAt = earliestOf(stamps);
const lastAt = latestOf(stamps);
const arrivalToFirst = elapsed(train?.arrivedAt, firstAt);
const departureToFirst = elapsed(train?.departedAt, firstAt);
const remove = useMutation({
mutationFn: (file: { id: string; name: string }) => {
setRemovingId(file.id);
return transitAssignmentsService.removeTransitDocument(bookingId, file.id);
},
onSuccess: (_r, file) => {
toast.success(`Removed ${file.name}`);
onChanged();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not remove the document"),
onSettled: () => setRemovingId(null),
});
return (
<>
<DocumentCard
icon={set.icon}
title={set.title}
subtitle={set.subtitle}
status={
items.length > 0 ? (
<Badge size="xs" color="edr-green" variant="light" radius="sm" tt="none">
{items.length} on file
</Badge>
) : (
<Badge size="xs" color="gray" variant="light" radius="sm" tt="none">
None yet
</Badge>
)
}
action={
<Button
color="edr-green"
radius="md"
size="sm"
variant={items.length > 0 ? "light" : "filled"}
leftSection={<Upload size={15} />}
onClick={() => setOpen(true)}
>
{items.length > 0 ? "Add documents" : "Upload documents"}
</Button>
}
>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Stat
icon={Clock3}
label="First uploaded"
value={firstAt ? formatStamp(firstAt) : "—"}
hint={
arrivalToFirst
? `${arrivalToFirst} after arrival`
: departureToFirst
? `${departureToFirst} after departure`
: "Measured against the train"
}
tone={firstAt ? "green" : "muted"}
/>
<Stat
icon={Clock3}
label="Latest uploaded"
value={lastAt ? formatStamp(lastAt) : "—"}
hint={
lastAt && lastAt !== firstAt
? `${elapsed(firstAt, lastAt)} after the first`
: lastAt
? "Single upload so far"
: "No uploads yet"
}
tone={lastAt ? "green" : "muted"}
/>
<Stat
icon={Train}
label={train?.arrivedAt ? "Since arrival" : "Since departure"}
value={
train?.arrivedAt
? formatDuration(Date.now() - new Date(train.arrivedAt).getTime())
: train?.departedAt
? formatDuration(Date.now() - new Date(train.departedAt).getTime())
: "—"
}
hint={
train?.arrivedAt
? `Arrived ${formatStamp(train.arrivedAt)}`
: train?.departedAt
? `Departed ${formatStamp(train.departedAt)}`
: "Train not departed yet"
}
tone={train?.arrivedAt ? "blue" : "muted"}
/>
</SimpleGrid>
{items.length > 0 ? (
<Stack gap={8}>
{items.map((item) => (
<StoredDocumentRow
key={item.code}
item={item}
train={train}
onView={onView}
onRemove={(f) => remove.mutate(f)}
removing={removingId === item.file?.id}
/>
))}
</Stack>
) : (
<EmptyDocs icon={set.icon}>{set.empty}</EmptyDocs>
)}
</Stack>
</DocumentCard>
<ArrivalUploadModal
kind={kind}
opened={open}
bookingId={bookingId}
existing={items.length}
onClose={() => setOpen(false)}
onSuccess={onChanged}
/>
</>
);
}
function ArrivalUploadModal({
kind,
opened,
bookingId,
existing,
onClose,
onSuccess,
}: {
kind: ArrivalKind;
opened: boolean;
bookingId: string;
existing: number;
onClose: () => void;
onSuccess: () => void;
}) {
const set = ARRIVAL_SETS[kind];
const [files, setFiles] = useState<File[]>([]);
const Icon = set.icon;
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: () => set.upload(bookingId, files),
onSuccess: (r) => {
toast.success(
`${r.uploaded} ${set.title} document${r.uploaded === 1 ? "" : "s"} uploaded`,
);
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="lg"
title={
<Group gap={8}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={30}>
<Icon size={16} />
</ThemeIcon>
<Text fw={700}>
{existing > 0 ? `Add ${set.title} documents` : `Upload ${set.title} documents`}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{existing > 0
? `${existing} already on file — these are added alongside them. `
: ""}
Each file is stamped with its upload time and measured against the
train's departure and arrival.
</Text>
<PortalMultiFileDropzone
label={`${set.title} files`}
description={set.modalHint}
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={submit.isPending}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
Upload{files.length > 0 ? ` ${files.length} file${files.length === 1 ? "" : "s"}` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
// ── Delivery Order card (import) ─────────────────────────────────────────────
function DeliveryOrderCard({
bookingId,
clearance,
history,
onView,
onChanged,
}: {
bookingId: string;
clearance: Freight.ClearanceView;
history: Freight.ClearanceHistoryEvent[];
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const doFiles = (clearance.workflowFiles ?? []).filter(
(f) => isDeliveryOrderFileCode(f.code) && f.file,
);
const hasDo = doFiles.length > 0;
// The DO clock starts when the booking is created. "Uploaded" is the latest
// stamp on the current DO set — replacing stores a fresh batch, so it is
// always the last update.
const createdAt = clearance.bookingCreatedAt ?? null;
const doEvents = history.filter((e) => e.action === "DELIVERY_ORDER_UPLOADED");
const doAt =
latestOf(doFiles.map((f) => f.file?.uploadedAt)) ?? doEvents[0]?.at ?? null;
const doUpdated = doEvents.length > 1;
const bookingToDo = elapsed(createdAt, doAt);
const collected =
clearance.milestones?.find((m) => m.milestoneCode === "DO_COLLECTED")
?.status === "COMPLETED";
const status = collected ? (
<Badge
size="xs"
color="edr-green"
variant="light"
radius="sm"
tt="none"
leftSection={<CheckCircle2 size={10} />}
>
Collected
</Badge>
) : hasDo ? (
<Badge size="xs" color="orange" variant="light" radius="sm" tt="none">
Uploaded
</Badge>
) : (
<Badge size="xs" color="blue" variant="light" radius="sm" tt="none">
Ready to upload
</Badge>
);
return (
<>
<DocumentCard
icon={PackageCheck}
title="Delivery Order"
subtitle="Collected at the Djibouti port, filed with the vessel arrival and collection dates"
status={status}
action={
<Button
color="edr-green"
radius="md"
size="sm"
leftSection={hasDo ? <FileCheck2 size={15} /> : <Upload size={15} />}
onClick={() => setOpen(true)}
>
{hasDo ? "Replace Delivery Order" : "Upload Delivery Order"}
</Button>
}
>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Stat
icon={Clock3}
label="Booking created"
value={createdAt ? formatStamp(createdAt) : "—"}
hint="The DO clock starts here"
tone={createdAt ? "blue" : "muted"}
/>
<Stat
icon={PackageCheck}
label={doUpdated ? "DO last updated" : "DO uploaded"}
value={doAt ? formatStamp(doAt) : "Not yet"}
hint={
doAt
? clearance.doCollectedDate
? `Collected ${clearance.doCollectedDate} · vessel ${clearance.vesselArrivalDate ?? "—"}`
: doUpdated
? `Replaced ${doEvents.length - 1}×`
: "First upload"
: "Upload to record the time"
}
tone={doAt ? "green" : "muted"}
/>
<Stat
icon={Timer}
label="Booking → DO"
value={bookingToDo ?? "—"}
hint={
bookingToDo
? doUpdated
? "Measured to the last update"
: "Time to collect the DO"
: "Measured once the DO is uploaded"
}
tone={bookingToDo ? "blue" : "muted"}
/>
</SimpleGrid>
{hasDo ? (
<Stack gap={8}>
{doFiles.map((item) => (
<StoredDocumentRow key={item.code} item={item} onView={onView} />
))}
</Stack>
) : (
<EmptyDocs icon={PackageCheck}>
No Delivery Order on file yet. Upload the DO and record when the
vessel arrived and when the DO was collected.
</EmptyDocs>
)}
</Stack>
</DocumentCard>
<DeliveryOrderModal
opened={open}
bookingId={bookingId}
replaceMode={hasDo}
vesselArrivalDate={clearance.vesselArrivalDate ?? null}
doCollectedDate={clearance.doCollectedDate ?? null}
onClose={() => setOpen(false)}
onSuccess={onChanged}
/>
</>
);
}
function DeliveryOrderModal({
opened,
bookingId,
replaceMode,
vesselArrivalDate,
doCollectedDate,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
replaceMode: boolean;
vesselArrivalDate: string | null;
doCollectedDate: string | null;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [vesselArrival, setVesselArrival] = useState<Date | null>(
vesselArrivalDate ? new Date(vesselArrivalDate) : null,
);
const [collected, setCollected] = useState<Date | null>(
doCollectedDate ? new Date(doCollectedDate) : null,
);
// The DO cannot be collected before the vessel docked.
const outOfOrder =
Boolean(vesselArrival && collected) &&
(toIsoDate(collected) ?? "") < (toIsoDate(vesselArrival) ?? "");
const datesComplete = Boolean(vesselArrival && collected) && !outOfOrder;
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.uploadDeliveryOrder(bookingId, files, {
vesselArrivalDate: toIsoDate(vesselArrival)!,
doCollectedDate: toIsoDate(collected)!,
}),
onSuccess: () => {
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="lg"
title={
<Group gap={8}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={30}>
<PackageCheck size={16} />
</ThemeIcon>
<Text fw={700}>
{replaceMode ? "Replace Delivery Order" : "Upload Delivery Order"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Upload the Djibouti Delivery Order and record when the vessel arrived
and when the DO was collected. The upload time is recorded and
measured from the booking's creation.
{replaceMode
? " Replacing removes the current DO files and records a new time."
: ""}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<DateInput
label="Vessel arrival date"
placeholder="Select date"
value={vesselArrival}
onChange={(v) => setVesselArrival(v ? new Date(v) : null)}
size="sm"
radius="md"
required
withAsterisk
/>
<DateInput
label="DO collected date"
placeholder="Select date"
value={collected}
onChange={(v) => setCollected(v ? new Date(v) : null)}
minDate={vesselArrival ?? undefined}
size="sm"
radius="md"
required
withAsterisk
error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined}
/>
</SimpleGrid>
<PortalMultiFileDropzone
label="Delivery Order files"
description="Scanned DO pages or the port's PDF. Every file in this batch replaces what is on file."
files={files}
onChange={setFiles}
accept="*/*"
acceptHint="any file type"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={submit.isPending}
disabled={files.length === 0 || !datesComplete}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
{replaceMode ? "Replace DO" : "Upload DO"}
{files.length > 1 ? ` (${files.length} files)` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
// ── T1 transport documents card (import) ────────────────────────────────────
function ImportT1Card({
bookingId,
clearance,
history,
onView,
onChanged,
}: {
bookingId: string;
clearance: Freight.ClearanceView;
history: Freight.ClearanceHistoryEvent[];
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const t1Files = (clearance.workflowFiles ?? []).filter(
(f) => isT1TransportFileCode(f.code) && f.file,
);
const hasT1 = t1Files.length > 0;
const train = clearance.train ?? null;
const departed = Boolean(train?.departedAt);
const closed = Boolean(clearance.t1Closed);
// Replacing stores a fresh batch, so the latest stamp is the last update.
const t1Events = history.filter((e) => e.action === "T1_DOCUMENTS_UPLOADED");
const t1At = latestOf(t1Files.map((f) => f.file?.uploadedAt)) ?? null;
const t1Updated = t1Events.length > 1;
const departureToT1 = elapsed(train?.departedAt, t1At);
const arrivalToT1 = elapsed(train?.arrivedAt, t1At);
const status = closed ? (
<Badge
size="xs"
color="edr-green"
variant="light"
radius="sm"
tt="none"
leftSection={<CheckCircle2 size={10} />}
>
Closed by GL Ethiopia
</Badge>
) : hasT1 ? (
<Badge size="xs" color="orange" variant="light" radius="sm" tt="none">
Uploaded
</Badge>
) : departed ? (
<Badge size="xs" color="blue" variant="light" radius="sm" tt="none">
Ready to upload
</Badge>
) : (
<Badge
size="xs"
color="gray"
variant="light"
radius="sm"
tt="none"
leftSection={<Lock size={10} />}
>
Waiting for departure
</Badge>
);
const locked = !departed || closed;
return (
<>
<DocumentCard
icon={FileStack}
title="T1 transport documents"
subtitle="Filed once the train departs Djibouti; final once GL Ethiopia closes the T1"
status={status}
action={
<Tooltip
label={
closed
? "GL Ethiopia has closed the T1 — documents are final"
: "The train has not departed yet"
}
disabled={!locked}
withArrow
>
<Button
color="edr-green"
radius="md"
size="sm"
leftSection={hasT1 ? <FileCheck2 size={15} /> : <Upload size={15} />}
disabled={locked}
onClick={() => setOpen(true)}
>
{hasT1 ? "Replace T1 documents" : "Upload T1 documents"}
</Button>
</Tooltip>
}
>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Stat
icon={Train}
label="Train departed"
value={train?.departedAt ? formatStamp(train.departedAt) : "Not departed"}
hint={departed ? "T1 upload unlocked" : "Unlocks the T1 upload"}
tone={departed ? "blue" : "muted"}
/>
<Stat
icon={FileStack}
label={t1Updated ? "T1 last updated" : "T1 uploaded"}
value={t1At ? formatStamp(t1At) : "Not yet"}
hint={
t1At
? closed && clearance.t1ClosedAt
? `Closed ${formatStamp(clearance.t1ClosedAt)}`
: t1Updated
? `Replaced ${t1Events.length - 1}×`
: "First upload"
: "Upload to record the time"
}
tone={t1At ? "green" : "muted"}
/>
<Stat
icon={Timer}
label={arrivalToT1 ? "Arrival → T1" : "Departure → T1"}
value={arrivalToT1 ?? departureToT1 ?? "—"}
hint={
arrivalToT1
? `${departureToT1} after departure`
: departureToT1
? train?.arrivedAt
? "Filed before arrival"
: "Train still in transit"
: "Measured once the T1 is uploaded"
}
tone={arrivalToT1 || departureToT1 ? "blue" : "muted"}
/>
</SimpleGrid>
{hasT1 ? (
<Stack gap={8}>
{t1Files.map((item) => (
<StoredDocumentRow key={item.code} item={item} train={train} onView={onView} />
))}
</Stack>
) : (
<EmptyDocs icon={FileStack}>
{departed
? "No T1 transport documents yet. Upload the T1 set — every file is time-stamped against departure and arrival."
: "T1 transport documents can be uploaded as soon as the train departs Djibouti."}
</EmptyDocs>
)}
</Stack>
</DocumentCard>
<ImportT1Modal
opened={open}
bookingId={bookingId}
replaceMode={hasT1}
onClose={() => setOpen(false)}
onSuccess={onChanged}
/>
</>
);
}
function ImportT1Modal({
opened,
bookingId,
replaceMode,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
replaceMode: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: () => transitAssignmentsService.uploadT1Documents(bookingId, files),
onSuccess: () => {
toast.success(replaceMode ? "T1 documents updated" : "T1 documents uploaded");
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="lg"
title={
<Group gap={8}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={30}>
<FileStack size={16} />
</ThemeIcon>
<Text fw={700}>
{replaceMode ? "Replace T1 transport documents" : "Upload T1 transport documents"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Upload the T1 set for this shipment. Every file is stamped with its
upload time and measured against the train's departure and arrival.
{replaceMode
? " Replacing removes the current T1 files and records a new time."
: ""}
</Text>
<PortalMultiFileDropzone
label="T1 transport documents"
description="Scans or photos of the T1 set. Every file in this batch replaces what is on file."
files={files}
onChange={setFiles}
accept="*/*"
acceptHint="any file type"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={submit.isPending}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
{replaceMode ? "Replace T1" : "Upload T1"}
{files.length > 1 ? ` (${files.length} files)` : ""}
</Button>
</Group>
</Stack>
</Modal>
);
}
// ── Panel ────────────────────────────────────────────────────────────────────
/** Header card shared by both directions: title, flow hint, train strip. */
function PanelHeader({
title,
subtitle,
flow,
train,
}: {
title: string;
subtitle: string;
flow: string[];
train?: Freight.ClearanceTrainState | null;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start" mb="sm" gap="md">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Train size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16} style={{ color: INK }}>
{title}
</Text>
<Text size="xs" c="dimmed">
{subtitle}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
{flow.map((step, i) => (
<Group key={step} gap={6} wrap="nowrap">
{i > 0 ? <ArrowRight size={12} color={MUTED} /> : null}
<Text
fz={11}
fw={700}
c={i === flow.length - 1 ? GREEN : MUTED}
tt="uppercase"
lts="0.3px"
>
{step}
</Text>
</Group>
))}
</Group>
</Group>
<TrainStrip train={train} />
</Card>
);
}
/**
* The transit agent's import paperwork on one shipment: the Delivery Order
* (timed from the booking's creation) and the T1 transport documents
* (unlocked by train departure, timed against departure and arrival). Both
* are replace-as-a-batch sets, so the stamps shown are always the last update.
*/
export function TransitImportDocumentsPanel({
bookingId,
clearance,
history,
onView,
onChanged,
}: {
bookingId: string;
clearance: Freight.ClearanceView;
history: Freight.ClearanceHistoryEvent[];
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const queryClient = useQueryClient();
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] });
void queryClient.invalidateQueries({ queryKey: ["transit-clearance-history"] });
onChanged();
};
return (
<Stack gap="md">
<PanelHeader
title="Import transit documents"
subtitle="Your paperwork on this shipment, with every upload time recorded against the booking and the train."
flow={["Booking", "DO", "Departure", "T1"]}
train={clearance.train}
/>
<DeliveryOrderCard
bookingId={bookingId}
clearance={clearance}
history={history}
onView={onView}
onChanged={refresh}
/>
<ImportT1Card
bookingId={bookingId}
clearance={clearance}
history={history}
onView={onView}
onChanged={refresh}
/>
</Stack>
);
}
/**
* The transit agent's export paperwork on one shipment: the Release Order
* (gated on the customs declaration, timed from it), and the gate pass and
* Djibouti T1 sets collected around train arrival (each file timed against
* the train's departure and arrival).
*
* Every timestamp here comes from the server — file `uploadedAt` stamps,
* milestone `triggeredAt`, the train state — so what the officer sees is what
* the desk and the customer's reports will also see.
*/
export function TransitExportDocumentsPanel({
bookingId,
clearance,
history,
onView,
onChanged,
}: {
bookingId: string;
clearance: Freight.ClearanceView;
history: Freight.ClearanceHistoryEvent[];
onView: (file: { name: string; url: string; mimeType?: string | null }) => void;
onChanged: () => void;
}) {
const queryClient = useQueryClient();
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] });
void queryClient.invalidateQueries({ queryKey: ["transit-clearance-history"] });
onChanged();
};
return (
<Stack gap="md">
<PanelHeader
title="Export transit documents"
subtitle="Your paperwork on this shipment, with every upload time recorded against the declaration and the train."
flow={["Declaration", "RO", "Arrival"]}
train={clearance.train}
/>
<ReleaseOrderCard
bookingId={bookingId}
clearance={clearance}
history={history}
onView={onView}
onChanged={refresh}
/>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<ArrivalDocumentsCard
kind="gate_pass"
bookingId={bookingId}
clearance={clearance}
onView={onView}
onChanged={refresh}
/>
<ArrivalDocumentsCard
kind="djibouti_t1"
bookingId={bookingId}
clearance={clearance}
onView={onView}
onChanged={refresh}
/>
</SimpleGrid>
</Stack>
);
}