Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx
Marshal 0d8c63328a add items per wagon map to cargo types and sync bulk rate units
- Implemented  in the  to manage the physical item capacity for each wagon type.
- Added a new migration to create the  column in the  table.
- Introduced  method in  to update rate units when cargo type unit of measure changes.
- Updated booking calculations to consider items per wagon for break-bulk cargo.
- Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application.
- Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings.
2026-08-01 09:55:21 +00:00

486 lines
14 KiB
TypeScript

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<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
{
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<Freight.GlExchangeDocument | null>(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 (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
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.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setFormDoc("new")}
>
Share document
</Button>
</Group>
{documents.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{isLoading ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : isError ? (
<Text size="sm" c="red">
Could not load the shared documents.
</Text>
) : documents.length === 0 ? (
<EmptyState onShare={() => setFormDoc("new")} />
) : (
<Stack gap={8}>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
onView={view}
onEdit={() => setFormDoc(doc)}
onDelete={() => setPendingDelete(doc)}
/>
))}
</Stack>
)}
<DocumentFormModal
entityId={entityId}
doc={formDoc === "new" ? null : formDoc}
opened={formDoc != null}
onClose={() => setFormDoc(null)}
onSaved={() => {
setFormDoc(null);
void invalidate();
}}
/>
<Modal
opened={pendingDelete != null}
onClose={() => setPendingDelete(null)}
title={<Text fw={700}>Remove shared document</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm">
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
desk and the customer, if it was shared will no longer see it.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeMutation.isPending}
leftSection={<Trash2 size={15} />}
onClick={() => removeMutation.mutate(pendingDelete!.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</Stack>
);
}
function EmptyState({ onShare }: { onShare: () => void }) {
return (
<Box
py={44}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={10} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={380}>
Nothing shared yet. Anything either desk uploads here scans,
correspondence, corrected forms is visible to the other side
immediately.
</Text>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={onShare}
>
Share the first document
</Button>
</Stack>
</Box>
);
}
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 (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={
doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />
}
>
{doc.visibleToCustomer ? "Visible to customer" : "GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{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,
})}
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(doc.file.id, doc.file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() =>
void downloadBookingFile(doc.file.id, doc.file.name)
}
>
Download
</Button>
</Tooltip>
{doc.canEdit ? (
<Menu position="bottom-end" radius="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
Edit title, visibility or file
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
onClick={onDelete}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
<ThemeIcon variant="subtle" color="gray" size={28}>
<UserCheck size={15} />
</ThemeIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
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<File | null>(null);
// Re-seed the form whenever a different document (or "new") opens it.
const [seededFor, setSeededFor] = useState<string | null>(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 (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Document title"
placeholder="e.g. Corrected packing list for container TCLU1234567"
description="What the other desk (and the customer, if shared) will see."
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={300}
required
/>
<PhasedFileDropzone
label={editing ? "Replacement file (optional)" : "File"}
description={
editing
? "Leave empty to keep the current file."
: "Any document type — PDF, image, spreadsheet."
}
accept="*/*"
value={file}
onChange={setFile}
replaceMode={editing}
/>
<Switch
checked={visible}
onChange={(e) => 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."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={save.isPending}
disabled={!title.trim() || (!editing && !file)}
leftSection={<Upload size={16} />}
onClick={() => save.mutate()}
>
{editing ? "Save changes" : "Share document"}
</Button>
</Group>
</Stack>
</Modal>
);
}