mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 17:23:38 +00:00
- 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.
486 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|