import { useMemo, useState } from "react"; import { ActionIcon, Badge, Box, Button, Group, Loader, Menu, Modal, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Download, Eye, EyeOff, FileText, MoreVertical, Pencil, Share2, Trash2, Upload, UserCheck, } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; import { isViewable } from "@edr/ui-common"; import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { useFileViewer } from "@/hooks/useFileViewer"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; import { glExchangeService } from "@/services/glExchange.service"; const SIDES: Record = { ET: { label: "GL Ethiopia", color: "edr-green" }, DJ: { label: "GL Djibouti", color: "blue" }, }; function formatBytes(bytes: number): string { if (!bytes) return "0 B"; const units = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`; } export interface GlExchangePanelProps { /** Booking or contract id both desks are working on — the thread key. */ entityId: string; } /** * GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file * under a title of its own choosing; both desks see the whole thread, only the * uploader can change or remove what they posted, and each document is shared * with the customer's portal or kept between the desks. */ export function GlExchangePanel({ entityId }: GlExchangePanelProps) { const queryClient = useQueryClient(); const { view, viewer } = useFileViewer(); const [formDoc, setFormDoc] = useState< Freight.GlExchangeDocument | "new" | null >(null); const [pendingDelete, setPendingDelete] = useState(null); const { data: documents = [], isLoading, isError, } = useQuery({ queryKey: ["gl-exchange", entityId], queryFn: () => glExchangeService.list(entityId), enabled: Boolean(entityId), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] }); const removeMutation = useMutation({ mutationFn: (id: string) => glExchangeService.remove(id), onSuccess: async () => { setPendingDelete(null); await invalidate(); toast.success("Document removed"); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not remove document"), }); const stats = useMemo( () => ({ et: documents.filter((d) => d.side === "ET").length, dj: documents.filter((d) => d.side === "DJ").length, shared: documents.filter((d) => d.visibleToCustomer).length, }), [documents], ); return ( Document exchange Share any document with the other Global Logistics desk. Both desks see everything here; only the uploader can edit or remove a document, and only documents marked visible reach the customer. {documents.length > 0 ? ( {stats.et} from GL Ethiopia {stats.dj} from GL Djibouti {stats.shared} visible to customer ) : null} {isLoading ? ( Loading shared documents… ) : isError ? ( Could not load the shared documents. ) : documents.length === 0 ? ( setFormDoc("new")} /> ) : ( {documents.map((doc) => ( setFormDoc(doc)} onDelete={() => setPendingDelete(doc)} /> ))} )} setFormDoc(null)} onSaved={() => { setFormDoc(null); void invalidate(); }} /> setPendingDelete(null)} title={Remove shared document} radius="md" size="sm" > Remove {pendingDelete?.title} from the exchange? The other desk — and the customer, if it was shared — will no longer see it. {viewer} ); } function EmptyState({ onShare }: { onShare: () => void }) { return ( Nothing shared yet. Anything either desk uploads here — scans, correspondence, corrected forms — is visible to the other side immediately. ); } function DocumentRow({ doc, onView, onEdit, onDelete, }: { doc: Freight.GlExchangeDocument; onView: (file: { name: string; url: string }) => void; onEdit: () => void; onDelete: () => void; }) { const side = SIDES[doc.side]; const canPreview = isViewable({ name: doc.file.name, url: "" }); return ( {doc.title} {side.label} : } > {doc.visibleToCustomer ? "Visible to customer" : "GL only"} {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} {doc.uploadedByName ?? "Global Logistics"} ·{" "} {new Date(doc.uploadedAt).toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, })} {canPreview ? ( ) : null} {doc.canEdit ? ( } onClick={onEdit}> Edit title, visibility or file } onClick={onDelete} > Remove ) : ( )} ); } function DocumentFormModal({ entityId, doc, opened, onClose, onSaved, }: { entityId: string; doc: Freight.GlExchangeDocument | null; opened: boolean; onClose: () => void; onSaved: () => void; }) { const editing = doc != null; const [title, setTitle] = useState(""); const [visible, setVisible] = useState(false); const [file, setFile] = useState(null); // Re-seed the form whenever a different document (or "new") opens it. const [seededFor, setSeededFor] = useState(null); const seedKey = opened ? (doc?.id ?? "new") : null; if (seedKey !== seededFor) { setSeededFor(seedKey); setTitle(doc?.title ?? ""); setVisible(doc?.visibleToCustomer ?? false); setFile(null); } const save = useMutation({ mutationFn: () => editing ? glExchangeService.update(doc.id, { title: title.trim(), visibleToCustomer: visible, file, }) : glExchangeService.upload(entityId, { title: title.trim(), visibleToCustomer: visible, file: file!, }), onSuccess: () => { toast.success(editing ? "Document updated" : "Document shared"); onSaved(); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Could not save document"), }); return ( {editing ? "Edit shared document" : "Share a document"} } radius="md" size="md" > setTitle(e.currentTarget.value)} maxLength={300} required /> setVisible(e.currentTarget.checked)} color="edr-green" label="Visible to the customer" description="Shows in the customer's booking documents. Off keeps it between the two GL desks." /> ); }