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 => stamps.reduce( (max, s) => (s && (!max || s > max) ? s : max), null, ); const earliestOf = (stamps: Array): string | null => stamps.reduce( (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 ( {label} {value} {hint ? ( {hint} ) : null} ); } function OffsetChip({ offset, color, }: { offset: { text: string; late: boolean } | null; color: string; }) { if (!offset) return null; return ( } > {offset.text} ); } /** 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 ( {item.label} {file.size ? ( · {formatBytes(file.size)} ) : null} {file.name} } > {formatStamp(uploadedAt)} {train ? ( <> ) : null} {canPreview ? ( void fetchViewableFile(file.id, file.name).then(onView) } > ) : null} void downloadStoredFile(file.id, file.name)} > {onRemove ? ( onRemove({ id: file.id, name: file.name })} > ) : null} ); } function EmptyDocs({ icon: Icon, children }: { icon: typeof FileText; children: React.ReactNode }) { return ( {children} ); } /** 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 ( {title} {status} {subtitle} {action ? {action} : null} {children} ); } // ── 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 ( ); } // ── 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 ? ( On hold ) : secured ? ( } > Secured ) : hasRo ? ( Uploaded ) : declared ? ( Ready to upload ) : ( } > Waiting for declaration ); return ( <> } > {hold ? ( } title="RO amendment hold" > {hold} ) : null} {hasRo ? ( {roFiles.map((item) => ( ))} ) : ( {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."} )} 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([]); const [vesselDate, setVesselDate] = useState( 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 ( {replaceMode ? "Replace Release Order" : "Upload Release Order"} } > 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." : ""} setVesselDate(v ? new Date(v) : null)} minDate={today} size="sm" radius="md" required withAsterisk /> ); } // ── 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(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 ( <> 0 ? ( {items.length} on file ) : ( None yet ) } action={ } > {items.length > 0 ? ( {items.map((item) => ( remove.mutate(f)} removing={removingId === item.file?.id} /> ))} ) : ( {set.empty} )} 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([]); 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 ( {existing > 0 ? `Add ${set.title} documents` : `Upload ${set.title} documents`} } > {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. ); } // ── 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 ? ( } > Collected ) : hasDo ? ( Uploaded ) : ( Ready to upload ); return ( <> : } onClick={() => setOpen(true)} > {hasDo ? "Replace Delivery Order" : "Upload Delivery Order"} } > {hasDo ? ( {doFiles.map((item) => ( ))} ) : ( No Delivery Order on file yet. Upload the DO and record when the vessel arrived and when the DO was collected. )} 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([]); const [vesselArrival, setVesselArrival] = useState( vesselArrivalDate ? new Date(vesselArrivalDate) : null, ); const [collected, setCollected] = useState( 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 ( {replaceMode ? "Replace Delivery Order" : "Upload Delivery Order"} } > 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." : ""} setVesselArrival(v ? new Date(v) : null)} size="sm" radius="md" required withAsterisk /> 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} /> ); } // ── 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 ? ( } > Closed by GL Ethiopia ) : hasT1 ? ( Uploaded ) : departed ? ( Ready to upload ) : ( } > Waiting for departure ); const locked = !departed || closed; return ( <> } > {hasT1 ? ( {t1Files.map((item) => ( ))} ) : ( {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."} )} 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([]); 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 ( {replaceMode ? "Replace T1 transport documents" : "Upload T1 transport documents"} } > 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." : ""} ); } // ── 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 ( {title} {subtitle} {flow.map((step, i) => ( {i > 0 ? : null} {step} ))} ); } /** * 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 ( ); } /** * 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 ( ); }