mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 11:53:38 +00:00
1737 lines
51 KiB
TypeScript
1737 lines
51 KiB
TypeScript
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>
|
||
);
|
||
}
|