mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge pull request #963 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
Container,
|
||||
@@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
@@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Truck />,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Wagon Transfers",
|
||||
href: "/dashboard/wagon-transfers",
|
||||
icon: <ArrowLeftRight />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
@@ -1180,6 +1191,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagon-transfers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
]}
|
||||
>
|
||||
<WagonTransfersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
@@ -1410,6 +1434,19 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagon-transfers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.wagons.transferView,
|
||||
FREIGHT_PERMS.wagons.view,
|
||||
]}
|
||||
>
|
||||
<WagonTransfersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Eye, History, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
|
||||
|
||||
export interface ClearanceDocumentVersionsModalProps {
|
||||
contractId: string;
|
||||
/** The document being inspected; null closes the modal. */
|
||||
doc: { fileKey: string; label: string } | null;
|
||||
onClose: () => void;
|
||||
/** Hide the replace form (finalized clearance, read-only viewers). */
|
||||
canReplace?: boolean;
|
||||
onReplaced?: () => void;
|
||||
onView?: (file: { name: string; url: string }) => void;
|
||||
}
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Version history of one clearance document, and the way to add a version.
|
||||
*
|
||||
* Staff can correct a document without bouncing it back to the customer, but
|
||||
* the customer's original is never overwritten — it drops down this list as a
|
||||
* superseded version, stamped with who replaced it and why. The corrected file
|
||||
* comes back unreviewed, so it still has to be approved before finalizing.
|
||||
*/
|
||||
export function ClearanceDocumentVersionsModal({
|
||||
contractId,
|
||||
doc,
|
||||
onClose,
|
||||
canReplace = false,
|
||||
onReplaced,
|
||||
onView,
|
||||
}: ClearanceDocumentVersionsModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const { data: versions = [], isLoading } = useQuery({
|
||||
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
|
||||
queryFn: () =>
|
||||
contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey),
|
||||
enabled: Boolean(doc),
|
||||
});
|
||||
|
||||
const replace = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.replaceClearanceDocument(
|
||||
contractId,
|
||||
doc!.fileKey,
|
||||
file!,
|
||||
reason.trim(),
|
||||
),
|
||||
onSuccess: async () => {
|
||||
toast.success("Document replaced — the previous version is kept on file");
|
||||
setFile(null);
|
||||
setReason("");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
});
|
||||
onReplaced?.();
|
||||
},
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
setReason("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(doc)}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
radius="md"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<History size={16} />
|
||||
<Text fw={700}>{doc?.label ?? "Document"} — version history</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : versions.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing uploaded under this document yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={8}>
|
||||
{versions.map((v, index) => (
|
||||
<Paper
|
||||
key={v.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
bg={v.isCurrent ? "var(--mantine-color-edr-green-0)" : undefined}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{v.name}
|
||||
</Text>
|
||||
{v.isCurrent ? (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
Current
|
||||
</Badge>
|
||||
) : index === versions.length - 1 ? (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
Original
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Superseded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Uploaded {fmt(v.uploadedAt)}
|
||||
{v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""}
|
||||
</Text>
|
||||
{v.replaceReason ? (
|
||||
<Text size="xs" c="orange.8">
|
||||
Reason: {v.replaceReason}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isViewable({ name: v.name, url: "" }) && onView ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
void fetchViewableFile(v.id, v.name).then(onView)
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => void downloadBookingFile(v.id, v.name)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{canReplace ? (
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={700}>
|
||||
Replace this document
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Use this for a correction you can make yourself. The customer's
|
||||
copy stays in the history above, and the new file has to be
|
||||
approved before clearance is finalized.
|
||||
</Text>
|
||||
<PhasedFileDropzone
|
||||
label="Corrected document"
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode
|
||||
/>
|
||||
<Textarea
|
||||
label="Why is it being replaced?"
|
||||
placeholder="e.g. customer sent page 2 only — attached the full signed copy"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={15} />}
|
||||
loading={replace.isPending}
|
||||
disabled={!file || !reason.trim()}
|
||||
onClick={() => replace.mutate()}
|
||||
>
|
||||
Replace document
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClearanceDocumentVersionsModal;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Eye,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
History,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
UserCheck,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { ClearanceDocumentVersionsModal } from "@/components/contracts/ClearanceDocumentVersionsModal";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -114,6 +116,11 @@ export function ContractClearanceReviewSection({
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
// Document whose version history is open (also hosts the replace form).
|
||||
const [historyDoc, setHistoryDoc] = useState<{
|
||||
fileKey: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
|
||||
@@ -264,6 +271,9 @@ export function ContractClearanceReviewSection({
|
||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||
}
|
||||
onView={view}
|
||||
onHistory={() =>
|
||||
setHistoryDoc({ fileKey: doc.fileKey, label: doc.label })
|
||||
}
|
||||
busy={reviewDocument.isPending}
|
||||
/>
|
||||
))
|
||||
@@ -487,6 +497,18 @@ export function ContractClearanceReviewSection({
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
{/* Version history + in-place correction. Replacing is blocked in the same
|
||||
situations queries are (finalized clearance / read-only audit view) —
|
||||
documents must not move once the gate has closed. */}
|
||||
<ClearanceDocumentVersionsModal
|
||||
contractId={contractId}
|
||||
doc={historyDoc}
|
||||
canReplace={!readOnly && !queriesLocked}
|
||||
onClose={() => setHistoryDoc(null)}
|
||||
onReplaced={onChanged}
|
||||
onView={view}
|
||||
/>
|
||||
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
@@ -534,6 +556,7 @@ function DocReviewCard({
|
||||
onApprove,
|
||||
onQuery,
|
||||
onView,
|
||||
onHistory,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
@@ -548,6 +571,7 @@ function DocReviewCard({
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onHistory: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
@@ -660,6 +684,21 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Every version ever stored under this key — the customer original
|
||||
plus any staff correction — and where a correction is made. */}
|
||||
{hasFile && (
|
||||
<Tooltip label="Versions & replace">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<History size={13} />}
|
||||
onClick={onHistory}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History } from "lucide-react";
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { History, User } from "lucide-react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Timeline,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -8,6 +17,8 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
|
||||
interface ContractRevisionTimelineProps {
|
||||
contractId: string;
|
||||
/** Rendered as a plain block instead of a SectionCard (own-tab layout). */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
type Change = Freight.IContractDocumentChange;
|
||||
@@ -21,8 +32,26 @@ const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> =
|
||||
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
||||
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
||||
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
||||
FIELD_CHANGED: { color: "orange", label: "Field" },
|
||||
};
|
||||
|
||||
/** Initials for the actor avatar — "Abenezer Haile" → "AH". */
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Role slugs arrive like "-marketing-director-"; render them readably. */
|
||||
function prettyRole(role: string): string {
|
||||
const cleaned = role.replace(/^-+|-+$/g, "").replace(/[-_]+/g, " ").trim();
|
||||
if (!cleaned) return role;
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/** What the change applies to — an article title, or the document itself. */
|
||||
function changeSubject(change: Change): string {
|
||||
switch (change.kind) {
|
||||
@@ -40,6 +69,8 @@ function changeSubject(change: Change): string {
|
||||
return `“${change.fromTitle}” → “${change.title}”`;
|
||||
case "ARTICLE_REORDERED":
|
||||
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
||||
case "FIELD_CHANGED":
|
||||
return `${change.label}: ${change.from ?? "—"} → ${change.to ?? "—"}`;
|
||||
default:
|
||||
return change.title;
|
||||
}
|
||||
@@ -53,82 +84,131 @@ function formatWhen(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** "3 hours ago" — the at-a-glance read; the exact stamp sits beside it. */
|
||||
function formatAgo(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
||||
for (const [unit, secondsPerUnit] of units) {
|
||||
if (seconds >= secondsPerUnit) {
|
||||
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
|
||||
}
|
||||
}
|
||||
return "just now";
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit trail of edits to the contract document. The document stays editable
|
||||
* through the approval chain, so this is the record of who changed what.
|
||||
*/
|
||||
export function ContractRevisionTimeline({
|
||||
contractId,
|
||||
bare = false,
|
||||
}: ContractRevisionTimelineProps) {
|
||||
const { data: revisions, isLoading } = useQuery({
|
||||
queryKey: ["contracts", contractId, "document-revisions"],
|
||||
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard icon={History} title="Document history">
|
||||
{isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading history…
|
||||
</Text>
|
||||
</Group>
|
||||
) : !revisions?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No edits recorded yet. Changes made to the contract articles during
|
||||
approval will appear here.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline
|
||||
active={revisions.length}
|
||||
bulletSize={18}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{revisions.map((revision) => (
|
||||
<Timeline.Item
|
||||
key={revision.id}
|
||||
title={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{revision.actorRole ?? "Staff"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatWhen(revision.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap={6} mt={4}>
|
||||
{revision.summary && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{revision.summary}
|
||||
</Text>
|
||||
const body = isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading history…
|
||||
</Text>
|
||||
</Group>
|
||||
) : !revisions?.length ? (
|
||||
<Stack gap={4} align="center" py="xl">
|
||||
<History size={26} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500}>
|
||||
No edits recorded yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={420}>
|
||||
Every change to this contract — its articles during review and approval,
|
||||
or its details while the customer can still edit it — is logged here
|
||||
with who made it and when.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline
|
||||
active={revisions.length}
|
||||
bulletSize={28}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{revisions.map((revision) => {
|
||||
const who = revision.actorName?.trim();
|
||||
const role = revision.actorRole ? prettyRole(revision.actorRole) : null;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={revision.id}
|
||||
bullet={
|
||||
<Avatar size={26} radius="xl" color="edr-green" variant="light">
|
||||
<Text size="10px" fw={700}>
|
||||
{who ? initials(who) : <User size={13} />}
|
||||
</Text>
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<Group gap="xs" wrap="wrap" align="baseline">
|
||||
<Text size="sm" fw={600}>
|
||||
{who ?? role ?? "Unknown user"}
|
||||
</Text>
|
||||
{role && who && (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{role}
|
||||
</Badge>
|
||||
)}
|
||||
{revision.changes.map((change, index) => {
|
||||
const style = CHANGE_STYLES[change.kind];
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={style?.color ?? "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{style?.label ?? change.kind}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
<Tooltip label={formatWhen(revision.createdAt)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatAgo(revision.createdAt)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap={6} mt={6} pb="xs">
|
||||
{revision.summary && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{revision.summary}
|
||||
</Text>
|
||||
)}
|
||||
{revision.changes.map((change, index) => {
|
||||
const style = CHANGE_STYLES[change.kind];
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={style?.color ?? "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{style?.label ?? change.kind}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||||
{changeSubject(change)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
);
|
||||
|
||||
if (bare) return body;
|
||||
return (
|
||||
<SectionCard icon={History} title="Change history">
|
||||
{body}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { Group } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
|
||||
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
|
||||
export 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);
|
||||
}
|
||||
|
||||
export interface DoCollectionDates {
|
||||
vesselArrival: Date | null;
|
||||
doCollected: Date | null;
|
||||
}
|
||||
|
||||
/** Both dates present and the DO not collected before the vessel docked. */
|
||||
export function doDatesComplete(dates: DoCollectionDates): boolean {
|
||||
if (!dates.vesselArrival || !dates.doCollected) return false;
|
||||
return (toIsoDate(dates.doCollected) ?? "") >= (toIsoDate(dates.vesselArrival) ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The two mandatory dates Djibouti GL records with a Delivery Order. Shared by
|
||||
* the DO upload modal and the inline DO step so neither surface can post an
|
||||
* upload the API will reject.
|
||||
*/
|
||||
export function DoCollectionDateFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DoCollectionDates;
|
||||
onChange: (next: DoCollectionDates) => void;
|
||||
}) {
|
||||
// Only complain once the user has actually entered the earlier date.
|
||||
const outOfOrder =
|
||||
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
|
||||
|
||||
return (
|
||||
<Group grow align="flex-start" gap="sm" wrap="wrap">
|
||||
<DateInput
|
||||
label="Vessel arrival date"
|
||||
placeholder="Select date"
|
||||
value={value.vesselArrival}
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
|
||||
}
|
||||
maxDate={new Date()}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
/>
|
||||
<DateInput
|
||||
label="DO collected date"
|
||||
placeholder="Select date"
|
||||
value={value.doCollected}
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, doCollected: v ? new Date(v) : null })
|
||||
}
|
||||
minDate={value.vesselArrival ?? undefined}
|
||||
maxDate={new Date()}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Local state helper — both surfaces need the same seed-from-server logic. */
|
||||
export function useDoCollectionDates(seed?: {
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
}) {
|
||||
return useState<DoCollectionDates>(() => ({
|
||||
vesselArrival: seed?.vesselArrivalDate ? new Date(seed.vesselArrivalDate) : null,
|
||||
doCollected: seed?.doCollectedDate ? new Date(seed.doCollectedDate) : null,
|
||||
}));
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import { DateInput } from "@mantine/dates";
|
||||
import { Ship, Upload } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
DoCollectionDateFields,
|
||||
doDatesComplete,
|
||||
toIsoDate,
|
||||
useDoCollectionDates,
|
||||
} from "@/components/contracts/DoCollectionDateFields";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -20,6 +26,9 @@ export interface GlClearanceUploadModalProps {
|
||||
isBooking: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
vesselDepartureDate?: string | null;
|
||||
/** Previously recorded DO dates, so a replace opens pre-filled. */
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onPreview?: (file: { name: string; url: string }) => void;
|
||||
}
|
||||
@@ -32,6 +41,8 @@ export function GlClearanceUploadModal({
|
||||
isBooking,
|
||||
workflowFiles = [],
|
||||
vesselDepartureDate,
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
onSuccess,
|
||||
onPreview,
|
||||
}: GlClearanceUploadModalProps) {
|
||||
@@ -39,6 +50,10 @@ export function GlClearanceUploadModal({
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||
);
|
||||
const [doDates, setDoDates] = useDoCollectionDates({
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Earliest selectable vessel date (today, local) — refreshed on each open.
|
||||
const todayISODate = useMemo(() => {
|
||||
@@ -65,15 +80,22 @@ export function GlClearanceUploadModal({
|
||||
toast.error("Vessel departure date is required.");
|
||||
return;
|
||||
}
|
||||
if (isDo && !doDatesComplete(doDates)) {
|
||||
toast.error("Vessel arrival date and DO collected date are both required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isDo) {
|
||||
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
|
||||
const dates = {
|
||||
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||
};
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, iso);
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
}
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
} else {
|
||||
@@ -113,7 +135,7 @@ export function GlClearanceUploadModal({
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isDo
|
||||
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
|
||||
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
|
||||
: "Upload the Release Order and confirm the vessel departure date."}
|
||||
</Text>
|
||||
|
||||
@@ -127,14 +149,7 @@ export function GlClearanceUploadModal({
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<DateInput
|
||||
label="Vessel arrival date (optional)"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
minDate={todayISODate}
|
||||
size="sm"
|
||||
clearable
|
||||
/>
|
||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||
)}
|
||||
|
||||
<PhasedFileDropzone
|
||||
@@ -154,7 +169,11 @@ export function GlClearanceUploadModal({
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file || (isRo && !vesselDate)}
|
||||
disabled={
|
||||
!file ||
|
||||
(isRo && !vesselDate) ||
|
||||
(isDo && !doDatesComplete(doDates))
|
||||
}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
@@ -269,6 +270,9 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// The customer states the billing currency on their shipment request — GL
|
||||
// books in it. Intercity is always ETB (the API enforces this too).
|
||||
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
@@ -439,6 +443,9 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
if (bookingRequest.contractRouteId)
|
||||
setContractRouteId(bookingRequest.contractRouteId);
|
||||
if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") {
|
||||
setPaymentCurrency(bookingRequest.paymentCurrency);
|
||||
}
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
|
||||
@@ -834,6 +841,7 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const payload: Freight.CreateBookingUnderContractDto = {
|
||||
...(contractRouteId ? { contractRouteId } : {}),
|
||||
paymentCurrency,
|
||||
// Intercity bookings carry no date — staff assign a passing train later.
|
||||
...(scheduledDate
|
||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||
@@ -1672,6 +1680,30 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
|
||||
<StepCard>
|
||||
<Box mb="md">
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
Billing currency
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
{isIntercity
|
||||
? "Intercity shipments are invoiced in ETB."
|
||||
: bookingRequest?.paymentCurrency
|
||||
? "Requested by the customer on their shipment request."
|
||||
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={isIntercity ? "ETB" : paymentCurrency}
|
||||
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
|
||||
disabled={isIntercity}
|
||||
data={[
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
]}
|
||||
color="edr-green"
|
||||
radius={10}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Textarea
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for this shipment…"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
type TransitPermitUploadedRow,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
PackageCheck,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
@@ -37,6 +39,12 @@ import toast from "react-hot-toast";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
||||
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
|
||||
import {
|
||||
DoCollectionDateFields,
|
||||
doDatesComplete,
|
||||
toIsoDate,
|
||||
useDoCollectionDates,
|
||||
} from "@/components/contracts/DoCollectionDateFields";
|
||||
import {
|
||||
findWorkflowFile,
|
||||
PhasedUploadedFileRow,
|
||||
@@ -53,6 +61,8 @@ export type ClearanceViewLike = Pick<
|
||||
| "nextAction"
|
||||
| "dutyRequired"
|
||||
| "dutyAdvice"
|
||||
| "dutyDispute"
|
||||
| "transitAssignee"
|
||||
| "roHold"
|
||||
| "roHoldReason"
|
||||
| "milestones"
|
||||
@@ -69,6 +79,8 @@ export type ClearanceViewLike = Pick<
|
||||
| "offloaded"
|
||||
| "finalInvoice"
|
||||
| "vesselDepartureDate"
|
||||
| "vesselArrivalDate"
|
||||
| "doCollectedDate"
|
||||
| "linkedBookingId"
|
||||
| "riskLevel"
|
||||
| "riskAssignedAt"
|
||||
@@ -332,8 +344,22 @@ export function PhasedClearanceActionPanel({
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Djibouti must name the transit officer first — the declaration
|
||||
is filed against whoever handles the shipment there, and the
|
||||
API refuses the upload until the name is in. */}
|
||||
{showEt &&
|
||||
canEt &&
|
||||
!isBooking &&
|
||||
!clearance.transitAssignee?.name &&
|
||||
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
|
||||
<TransitAssigneePanel
|
||||
contractId={entityId}
|
||||
transitAssignee={clearance.transitAssignee}
|
||||
side="ET"
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : showEt &&
|
||||
canEt &&
|
||||
!clearance.bookingReady &&
|
||||
(activeStep >= 1 ||
|
||||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
|
||||
@@ -504,6 +530,8 @@ export function PhasedClearanceActionPanel({
|
||||
isBooking={isBooking}
|
||||
workflowFiles={workflowFiles}
|
||||
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
|
||||
vesselArrivalDate={clearance.vesselArrivalDate}
|
||||
doCollectedDate={clearance.doCollectedDate}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
@@ -1569,9 +1597,35 @@ function DutyStep({
|
||||
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
|
||||
|
||||
const hasExistingNotice = Boolean(noticeFile);
|
||||
const dispute = clearance.dutyDispute;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* The customer rejected the last advice — their words drive the
|
||||
correction, so they lead the step. */}
|
||||
{dispute ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={16} />}
|
||||
title={
|
||||
dispute.rounds > 1
|
||||
? `Customer asked for a correction (round ${dispute.rounds})`
|
||||
: "Customer asked for a correction"
|
||||
}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{dispute.note}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Raised {new Date(dispute.raisedAt).toLocaleString()} — re-advise
|
||||
below to send a corrected notice.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{noticeFile ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
@@ -1765,6 +1819,8 @@ function DeliveryOrderStep({
|
||||
onChanged,
|
||||
workflowFiles = [],
|
||||
replaceMode = false,
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
@@ -1773,12 +1829,18 @@ function DeliveryOrderStep({
|
||||
onChanged?: () => void;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
replaceMode?: boolean;
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
delivery_order: null,
|
||||
});
|
||||
const [doDates, setDoDates] = useDoCollectionDates({
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasFile = Boolean(files.delivery_order);
|
||||
|
||||
@@ -1790,20 +1852,27 @@ function DeliveryOrderStep({
|
||||
workflowFiles={workflowFiles}
|
||||
replaceMode={replaceMode}
|
||||
loading={loading}
|
||||
disabled={!hasFile}
|
||||
helperText="Upload the Djibouti Delivery Order (DO) for this shipment."
|
||||
disabled={!hasFile || !doDatesComplete(doDates)}
|
||||
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
|
||||
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
|
||||
extraFields={
|
||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onSubmit={async () => {
|
||||
const file = files.delivery_order;
|
||||
if (!file) return;
|
||||
if (!file || !doDatesComplete(doDates)) return;
|
||||
const dates = {
|
||||
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
}
|
||||
setFiles({ delivery_order: null });
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, Upload } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
@@ -11,6 +12,8 @@ export interface PhasedDocumentUploadFieldProps {
|
||||
onChange: (key: string, file: File | null) => void;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
helperText: string;
|
||||
/** Extra inputs rendered above the dropzones (e.g. required DO dates). */
|
||||
extraFields?: ReactNode;
|
||||
replaceMode?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -27,6 +30,7 @@ export function PhasedDocumentUploadField({
|
||||
onChange,
|
||||
workflowFiles = [],
|
||||
helperText,
|
||||
extraFields,
|
||||
replaceMode = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
@@ -87,6 +91,7 @@ export function PhasedDocumentUploadField({
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
{extraFields}
|
||||
{fields.map((f) => (
|
||||
<PhasedFileDropzone
|
||||
key={f.key}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export interface TransitAssigneePanelProps {
|
||||
contractId: string;
|
||||
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
|
||||
/**
|
||||
* ET asks and waits; DJ answers with a name. The same state renders from both
|
||||
* desks — only the action on offer differs.
|
||||
*/
|
||||
side: "ET" | "DJ";
|
||||
/** Hide the action (read-only audit view, or the user lacks the permission). */
|
||||
readOnly?: boolean;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const fmt = (iso?: string | null) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "—";
|
||||
|
||||
/**
|
||||
* The transit-assignee handshake that gates the customs declaration.
|
||||
*
|
||||
* GL Ethiopia cannot file a declaration until Djibouti says who will handle the
|
||||
* shipment on their side, so ET raises the ask here and Djibouti answers with a
|
||||
* name (free text — the officer is not a platform user). Djibouti can send a
|
||||
* different name later; the newest one wins and Ethiopia is notified again.
|
||||
*/
|
||||
export function TransitAssigneePanel({
|
||||
contractId,
|
||||
transitAssignee,
|
||||
side,
|
||||
readOnly = false,
|
||||
onChanged,
|
||||
}: TransitAssigneePanelProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
|
||||
const [changing, setChanging] = useState(false);
|
||||
|
||||
const request = useMutation({
|
||||
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
|
||||
onSuccess: () => {
|
||||
toast.success("Request sent to GL Djibouti");
|
||||
setNote("");
|
||||
onChanged?.();
|
||||
},
|
||||
});
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.assignTransitAssignee(contractId, assignee.trim()),
|
||||
onSuccess: () => {
|
||||
toast.success("Transit assignee sent to GL Ethiopia");
|
||||
setChanging(false);
|
||||
onChanged?.();
|
||||
},
|
||||
});
|
||||
|
||||
const requested = Boolean(transitAssignee?.requestedAt);
|
||||
const assigned = Boolean(transitAssignee?.name);
|
||||
|
||||
// Settled state — both desks see the same line; DJ keeps a way to change it.
|
||||
if (assigned && !changing) {
|
||||
return (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
title="Transit assignee confirmed"
|
||||
>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
<Text span fw={700}>
|
||||
{transitAssignee!.name}
|
||||
</Text>{" "}
|
||||
will handle this shipment in transit · assigned{" "}
|
||||
{fmt(transitAssignee!.assignedAt)}
|
||||
</Text>
|
||||
{side === "DJ" && !readOnly ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setAssignee(transitAssignee!.name ?? "");
|
||||
setChanging(true);
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Djibouti's desk: answer the ask.
|
||||
if (side === "DJ") {
|
||||
if (!requested) {
|
||||
return (
|
||||
<Alert color="gray" radius="md" icon={<Clock size={16} />}>
|
||||
GL Ethiopia has not requested a transit assignee for this clearance yet.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap={8}>
|
||||
<UserCheck size={16} />
|
||||
<Text fw={700} size="sm">
|
||||
{changing ? "Change the transit assignee" : "Assign a transit officer"}
|
||||
</Text>
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
Requested {fmt(transitAssignee?.requestedAt)}
|
||||
</Badge>
|
||||
</Group>
|
||||
{transitAssignee?.requestNote ? (
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
|
||||
GL Ethiopia: {transitAssignee.requestNote}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Transit officer"
|
||||
description="Name of the person handling this shipment in Djibouti"
|
||||
placeholder="e.g. Ahmed Bourhan"
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.currentTarget.value)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{changing ? (
|
||||
<Button variant="default" radius="md" onClick={() => setChanging(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
loading={assign.isPending}
|
||||
disabled={readOnly || !assignee.trim()}
|
||||
onClick={() => assign.mutate()}
|
||||
>
|
||||
Send assignment
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// Ethiopia's desk: raise the ask, then wait.
|
||||
if (requested) {
|
||||
return (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<Clock size={16} />}
|
||||
title="Waiting for GL Djibouti"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">
|
||||
Requested {fmt(transitAssignee?.requestedAt)}. The customs declaration
|
||||
opens once Djibouti names the transit officer.
|
||||
</Text>
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
loading={request.isPending}
|
||||
onClick={() => request.mutate()}
|
||||
>
|
||||
Send a reminder
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap={8}>
|
||||
<UserCheck size={16} />
|
||||
<Text fw={700} size="sm">
|
||||
Request a transit assignee
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
GL Djibouti must name the officer handling this shipment in transit
|
||||
before the customs declaration can be filed.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Note for GL Djibouti (optional)"
|
||||
placeholder="Anything they need to know to pick the right officer"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
loading={request.isPending}
|
||||
disabled={readOnly}
|
||||
onClick={() => request.mutate()}
|
||||
>
|
||||
Request assignee
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransitAssigneePanel;
|
||||
@@ -147,7 +147,7 @@ export const BookingDetailModal = ({
|
||||
<Group gap={4} justify="flex-end">
|
||||
{bookingWagons.map((w) => (
|
||||
<Badge key={w.id} size="sm" variant="outline" color="edr-green" radius="sm">
|
||||
#{w.sequenceNo}
|
||||
#{w.position ?? w.sequenceNo}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -281,7 +281,7 @@ function WagonCar({
|
||||
{/* header */}
|
||||
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||
<Text size="10px" fw={800} c="gray.7">
|
||||
#{wagon.sequenceNo}
|
||||
#{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
{isEmpty ? (
|
||||
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||
@@ -425,7 +425,7 @@ function WagonCar({
|
||||
</Box>
|
||||
<div>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const RemoveBookingModal = ({
|
||||
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||
<strong>Wagon Slot:</strong> #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export const TrainConsistView = ({
|
||||
<Box>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers, move containers to another wagon, or remove the booking
|
||||
|
||||
@@ -80,7 +80,7 @@ export const WagonCard = ({
|
||||
<div>
|
||||
<Group gap={4}>
|
||||
<Text size="sm" fw={800}>
|
||||
Wagon #{wagon.sequenceNo}
|
||||
Wagon #{wagon.position ?? wagon.sequenceNo}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{wagonType}
|
||||
@@ -205,7 +205,7 @@ export const WagonCard = ({
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
#{w.sequenceNo} ·{" "}
|
||||
#{w.position ?? w.sequenceNo} ·{" "}
|
||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
<Badge
|
||||
|
||||
@@ -1,613 +0,0 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PENDING = Freight.WagonTransferRequestStatus.Pending;
|
||||
const AVAILABLE = Freight.WagonStatus.Available;
|
||||
|
||||
const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
const typeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
|
||||
|
||||
/** Requester → destination + type + count summary line, reused in list and picker. */
|
||||
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={14} style={{ flexShrink: 0 }} />
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
{r.quantity}× {typeLabel(r.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
FULFILLED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
||||
* backend enforces the scope regardless of the toggle.
|
||||
*/
|
||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
||||
);
|
||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions(),
|
||||
enabled: opened && !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
||||
enabled: opened && scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
||||
|
||||
const roleBadge = (r: WagonTransferRequest) => {
|
||||
if (myId && r.fulfilledByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
fulfilled
|
||||
</Badge>
|
||||
);
|
||||
if (myId && r.requestedByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
requested
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{canSeeAll ? (
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={allStaff}
|
||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
||||
label="All staff"
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Requests{scopeAll ? "" : " you touched"}
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No requests yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<RequestSummary r={r} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{roleBadge(r)}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
||||
>
|
||||
{r.status.toLowerCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
{r.reason ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Reason: {r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Wagons moved
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon moves yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap={6}>
|
||||
{movements.map((m) => (
|
||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
||||
</Text>
|
||||
{m.transferRequestId ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
from request
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
* A second tab shows per-user transfer history.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
// Bulk accept-and-execute: the subset of pending requests OCC ticked.
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: requests = [], isLoading } = useQuery({
|
||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
// Available wagons of the requested type sitting in the request's source yard.
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: active
|
||||
? {
|
||||
currentYardId: active.fromYardId,
|
||||
wagonTypeId: active.wagonTypeId,
|
||||
status: AVAILABLE,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: opened && Boolean(active),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
const bulkFulfill = useMutation(
|
||||
api.wagonTransferRequests.bulkFulfill.mutationOptions(),
|
||||
);
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? fallback;
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const openPicker = (r: WagonTransferRequest) => {
|
||||
setActive(r);
|
||||
setPicked(new Set());
|
||||
};
|
||||
const closePicker = () => {
|
||||
setActive(null);
|
||||
setPicked(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (active && next.size >= active.quantity) return prev; // cap at quantity
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const need = active?.quantity ?? 0;
|
||||
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
|
||||
|
||||
const handleFulfill = async () => {
|
||||
if (!active || picked.size !== need) return;
|
||||
try {
|
||||
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
|
||||
toast({
|
||||
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
|
||||
active.toYard,
|
||||
)}`,
|
||||
});
|
||||
closePicker();
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (r: WagonTransferRequest) => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast({ title: "Request cancelled" });
|
||||
} catch (err) {
|
||||
showError(err, "Cancel failed");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelected = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Execute the ticked subset; whatever cannot run (not enough available
|
||||
// wagons, already decided) is reported and simply stays PENDING.
|
||||
const handleBulkFulfill = async () => {
|
||||
if (selected.size === 0) return;
|
||||
try {
|
||||
const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] });
|
||||
setSelected(new Set());
|
||||
const skippedNote = res.skipped.length
|
||||
? ` · ${res.skipped.length} left pending (${res.skipped
|
||||
.map((s) => s.reason)
|
||||
.join('; ')})`
|
||||
: "";
|
||||
toast({
|
||||
title: `Executed ${res.fulfilled.length} transfer request(s)`,
|
||||
description: skippedNote || undefined,
|
||||
variant: res.fulfilled.length === 0 ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err, "Bulk execute failed");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedWagons = useMemo(
|
||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||
[wagons],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="min(760px, 96vw)"
|
||||
radius="lg"
|
||||
centered
|
||||
overlayProps={{ blur: 2 }}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Inbox size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Wagon Transfer Requests</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{active
|
||||
? "Pick the wagons to move, then transfer"
|
||||
: "OCC queue — pick wagons and complete each move"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
||||
Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="queue">
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : requests.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>No pending transfer requests</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
When staff request a yard-to-yard wagon move, it appears here for
|
||||
you to fulfil.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{/* Bulk accept-and-execute action bar: tick a subset, run it, and
|
||||
everything unticked (or unexecutable) stays PENDING. */}
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Checkbox
|
||||
label={
|
||||
selected.size > 0
|
||||
? `${selected.size} of ${requests.length} selected`
|
||||
: "Select all"
|
||||
}
|
||||
checked={selected.size === requests.length && requests.length > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < requests.length}
|
||||
onChange={() =>
|
||||
setSelected(
|
||||
selected.size === requests.length
|
||||
? new Set()
|
||||
: new Set(requests.map((r) => r.id)),
|
||||
)
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
loading={bulkFulfill.isPending}
|
||||
disabled={selected.size === 0}
|
||||
onClick={handleBulkFulfill}
|
||||
>
|
||||
Accept & execute {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={selected.has(r.id)}
|
||||
onChange={() => toggleSelected(r.id)}
|
||||
color="edr-green"
|
||||
mt={2}
|
||||
/>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.reason ? (
|
||||
<Text size="xs">
|
||||
<Text span fw={600}>
|
||||
Reason:
|
||||
</Text>{" "}
|
||||
{r.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
loading={cancel.isPending}
|
||||
onClick={() => handleCancel(r)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
onClick={() => openPicker(r)}
|
||||
>
|
||||
Fulfil
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
) : (
|
||||
// ---- Wagon picker for the active request ----
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
|
||||
<RequestSummary r={active} />
|
||||
</Card>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Select wagons in {yardLabel(active.fromYard)}
|
||||
</Text>
|
||||
<Badge
|
||||
color={picked.size === need ? "teal" : "gray"}
|
||||
variant={picked.size === need ? "filled" : "light"}
|
||||
>
|
||||
{picked.size} / {need} selected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{wagonsLoading ? (
|
||||
<Group justify="center" p="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : sortedWagons.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group gap={8} justify="center">
|
||||
<Warehouse size={16} />
|
||||
<Text size="sm" c="dimmed">
|
||||
No available wagons of this type in {yardLabel(active.fromYard)}.
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{shortfall > 0 ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Only {sortedWagons.length} available — {shortfall} short of the{" "}
|
||||
{need} requested.
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={6}>
|
||||
{sortedWagons.map((w) => {
|
||||
const checked = picked.has(w.id);
|
||||
const atCap = !checked && picked.size >= need;
|
||||
return (
|
||||
<Card
|
||||
key={w.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="xs"
|
||||
onClick={() => !atCap && toggle(w.id)}
|
||||
style={{
|
||||
cursor: atCap ? "not-allowed" : "pointer",
|
||||
borderColor: checked
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: undefined,
|
||||
opacity: atCap ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{/* Visual only — the Card's onClick owns the toggle so a
|
||||
click on the box doesn't fire both and cancel out. */}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
readOnly
|
||||
disabled={atCap}
|
||||
color="edr-green"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={closePicker}
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size !== need}
|
||||
onClick={handleFulfill}
|
||||
>
|
||||
Transfer {need} wagon{need === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonTransferRequestsModal;
|
||||
@@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => {
|
||||
return Math.min(Math.floor(n), max);
|
||||
};
|
||||
|
||||
/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */
|
||||
/**
|
||||
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
|
||||
* for actions that move real wagons; omit it for a transfer REQUEST, which may
|
||||
* legitimately ask for more than the yard holds today (OCC fulfils it in
|
||||
* instalments) — the slider then just tracks the current value.
|
||||
*/
|
||||
const QuantityField = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -49,10 +54,11 @@ const QuantityField = ({
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
max: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const set = (v: number | string) => onChange(clampInt(v, max));
|
||||
const capped = max ?? Number.MAX_SAFE_INTEGER;
|
||||
const set = (v: number | string) => onChange(clampInt(v, capped));
|
||||
const off = disabled || max === 0;
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
@@ -73,19 +79,25 @@ const QuantityField = ({
|
||||
value={value}
|
||||
onChange={set}
|
||||
min={0}
|
||||
max={Math.max(max, 1)}
|
||||
max={Math.max(max ?? Math.max(value, 10), 1)}
|
||||
disabled={off}
|
||||
label={(v) => `${v}`}
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
{/* Presets only make sense against a real ceiling — an uncapped request
|
||||
field (transfer ask) shows the manual input alone. */}
|
||||
{max != null ? (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(Math.ceil(max / 2))}>
|
||||
Half
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="gray" disabled={off} onClick={() => set(max)}>
|
||||
All ({max})
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{value > 0 ? (
|
||||
<Button size="compact-xs" variant="subtle" color="gray" onClick={() => set(0)}>
|
||||
Clear
|
||||
@@ -242,9 +254,10 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Keep quantities within bounds as counts shift after each action. Transfers
|
||||
// may only ask for AVAILABLE wagons, so the request cap is availableCount.
|
||||
useEffect(() => setTransferQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
// Keep quantities within bounds as counts shift after each action. A TRANSFER
|
||||
// REQUEST is deliberately uncapped: OCC delivers in instalments, so asking for
|
||||
// 50 where 20 sit today is normal — only the status flips below are bounded by
|
||||
// what is physically in the yard.
|
||||
useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]);
|
||||
useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]);
|
||||
|
||||
@@ -453,11 +466,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{availableCount} available
|
||||
</Badge>
|
||||
</Group>
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
max={availableCount}
|
||||
/>
|
||||
{/* No max: the request may exceed what the yard holds
|
||||
today — OCC fulfils it in instalments. */}
|
||||
<QuantityField value={transferQty} onChange={setTransferQty} />
|
||||
</div>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
|
||||
@@ -186,6 +186,16 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
|
||||
/** Pre-declaration transit-assignee handshake (ET asks, DJ answers). */
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
|
||||
`/contracts/${id}/clearance/transit-assignee/request`,
|
||||
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
|
||||
`/contracts/${id}/clearance/transit-assignee/assign`,
|
||||
/** Staff corrects a clearance document in place; the old version is kept. */
|
||||
CLEARANCE_DOC_REPLACE: (id: string, fileKey: string) =>
|
||||
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/replace`,
|
||||
CLEARANCE_DOC_VERSIONS: (id: string, fileKey: string) =>
|
||||
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/versions`,
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||
|
||||
@@ -130,6 +130,10 @@ export const FREIGHT_PERMS = {
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
/** Open the transfer desk. `wagons:view` is accepted as a fallback. */
|
||||
transferView: "edr_freight_app:wagons:transfer_view",
|
||||
transferCancel: "edr_freight_app:wagons:transfer_cancel",
|
||||
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FileText,
|
||||
Files,
|
||||
Flame,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
@@ -295,7 +296,9 @@ export default function ContractRequestDetailPage() {
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: "details";
|
||||
: requestedTab === "history"
|
||||
? "history"
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -475,6 +478,9 @@ export default function ContractRequestDetailPage() {
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
@@ -488,7 +494,6 @@ export default function ContractRequestDetailPage() {
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractRevisionTimeline contractId={contract.id} />
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
@@ -509,6 +514,14 @@ export default function ContractRequestDetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "history" ? (
|
||||
<SectionCard
|
||||
icon={History}
|
||||
title="Change history"
|
||||
subtitle="Every recorded edit to this contract — who changed what, and when."
|
||||
>
|
||||
<ContractRevisionTimeline contractId={contract.id} bare />
|
||||
</SectionCard>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type GlClearanceUploadKind,
|
||||
} from "@/components/contracts/GlClearanceUploadModal";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
@@ -235,6 +236,23 @@ export default function GlClearanceDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
{/* GL Ethiopia cannot file the customs declaration until this desk
|
||||
names the officer handling the shipment in transit, so the ask
|
||||
sits above everything else on the page. */}
|
||||
{data.kind === "contract" ? (
|
||||
<Box mb="md">
|
||||
<TransitAssigneePanel
|
||||
contractId={id!}
|
||||
transitAssignee={data.clearance.transitAssignee}
|
||||
side="DJ"
|
||||
readOnly={
|
||||
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{data.kind === "booking" ? (
|
||||
@@ -345,6 +363,8 @@ export default function GlClearanceDetailPage() {
|
||||
isBooking={data.kind === "booking"}
|
||||
workflowFiles={workflowFiles}
|
||||
vesselDepartureDate={vesselDepartureDate}
|
||||
vesselArrivalDate={data.clearance.vesselArrivalDate}
|
||||
doCollectedDate={data.clearance.doCollectedDate}
|
||||
onSuccess={() => void refetch()}
|
||||
onPreview={view}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions"
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
@@ -19,7 +19,6 @@ import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { matchesDayRange } from "@/hooks/useListControls";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -67,7 +66,6 @@ const FleetResourcePage = () => {
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
@@ -90,6 +88,11 @@ const FleetResourcePage = () => {
|
||||
if (trainNumber && trainNumber !== "ALL") {
|
||||
(filters as { trainNumber?: string }).trainNumber = trainNumber;
|
||||
}
|
||||
// Wagons only: narrow the fleet to one wagon type (the API filters on it).
|
||||
const wagonTypeId = listFilterValues.wagonTypeId;
|
||||
if (wagonTypeId && wagonTypeId !== "ALL") {
|
||||
(filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId;
|
||||
}
|
||||
if (slug !== "locomotives" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
}
|
||||
@@ -449,12 +452,15 @@ const FleetResourcePage = () => {
|
||||
</Button>
|
||||
) : null}
|
||||
{canTransfer ? (
|
||||
// The desk is its own page now (list + fulfil + history with
|
||||
// pagination); this is just the way in from the fleet list.
|
||||
<Button
|
||||
component={Link}
|
||||
to="/dashboard/wagon-transfers"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
@@ -713,13 +719,6 @@ const FleetResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonTransferRequestsModal
|
||||
opened={transferRequestsOpen}
|
||||
onClose={() => setTransferRequestsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
|
||||
@@ -305,6 +305,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
allLabel: "All statuses",
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
allLabel: "All types",
|
||||
dynamicOptions: "wagonTypes",
|
||||
},
|
||||
{
|
||||
key: "currentYardId",
|
||||
label: "Current Yard",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||
|
||||
export interface TransferFulfillModalProps {
|
||||
request: WagonTransferRequest | null;
|
||||
onClose: () => void;
|
||||
onDone?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move wagons against an open request. Any number from one up to whatever is
|
||||
* still owed — a yard that can only spare 20 of 50 sends 20 now and the request
|
||||
* stays open for the rest, so the picker caps at the OUTSTANDING count, not the
|
||||
* originally requested one.
|
||||
*/
|
||||
export function TransferFulfillModal({
|
||||
request,
|
||||
onClose,
|
||||
onDone,
|
||||
}: TransferFulfillModalProps) {
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const outstanding = request ? outstandingOn(request) : 0;
|
||||
|
||||
const { data: wagons = [], isLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: request
|
||||
? {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: Freight.WagonStatus.Available,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: Boolean(request),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
|
||||
const canTake = useMemo(
|
||||
() => Math.min(outstanding, wagons.length),
|
||||
[outstanding, wagons.length],
|
||||
);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
// Never let staff pick more than is still owed — the API rejects it too.
|
||||
else if (next.size >= outstanding) return prev;
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const takeAllAvailable = () =>
|
||||
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
|
||||
|
||||
const close = () => {
|
||||
setPicked(new Set());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!request || picked.size === 0) return;
|
||||
try {
|
||||
const moved = picked.size;
|
||||
await fulfill.mutateAsync({ id: request.id, wagonIds: [...picked] });
|
||||
toast.success(
|
||||
moved >= outstanding
|
||||
? `Request complete — ${moved} wagon(s) transferred`
|
||||
: `${moved} wagon(s) transferred · ${outstanding - moved} still owed`,
|
||||
);
|
||||
close();
|
||||
onDone?.();
|
||||
} catch {
|
||||
// The http interceptor surfaces the server's reason.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(request)}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
radius="md"
|
||||
title={
|
||||
request ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={700}>{yardLabel(request.fromYard)}</Text>
|
||||
<ArrowRight size={15} />
|
||||
<Text fw={700}>{yardLabel(request.toYard)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!request ? null : (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text size="sm">
|
||||
<Text span fw={700}>
|
||||
{outstanding}
|
||||
</Text>{" "}
|
||||
wagon(s) still owed ·{" "}
|
||||
<Text span fw={700}>
|
||||
{wagons.length}
|
||||
</Text>{" "}
|
||||
available in {yardLabel(request.fromYard)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={canTake === 0}
|
||||
onClick={takeAllAvailable}
|
||||
>
|
||||
Select {canTake}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{wagons.length < outstanding ? (
|
||||
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
This yard can only cover {wagons.length} of the {outstanding}{" "}
|
||||
outstanding. Send what is here — the request stays open for the
|
||||
rest, or close it short so the requester can ask another yard.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" py="md">
|
||||
No available wagons of this type in the source yard right now.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={4}>
|
||||
{wagons.map((w) => (
|
||||
<Checkbox
|
||||
key={w.id}
|
||||
checked={picked.has(w.id)}
|
||||
onChange={() => toggle(w.id)}
|
||||
label={w.wagonNumber}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size === 0}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Transfer {picked.size || ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransferFulfillModal;
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Send, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui";
|
||||
|
||||
/** Yard + wagon-type option lists, shared by both modals. */
|
||||
function useTransferOptions(enabled: boolean) {
|
||||
const { data: yards = [] } = useQuery({
|
||||
...api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
enabled,
|
||||
});
|
||||
const { data: wagonTypes = [] } = useQuery({
|
||||
...api.wagonTypes.list.queryOptions(),
|
||||
enabled,
|
||||
});
|
||||
return {
|
||||
yardOptions: yards.map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
})),
|
||||
typeOptions: wagonTypes.map((t) => ({
|
||||
value: t.id,
|
||||
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferRequestFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Carry-over from a request that could not be met in full: the destination,
|
||||
* type, outstanding count and reason are pre-filled and the user only picks
|
||||
* WHICH other yard to ask. Undefined for a plain new request.
|
||||
*/
|
||||
prefillFrom?: WagonTransferRequest | null;
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a wagon-transfer request. The count is deliberately NOT capped by what
|
||||
* the source yard holds today — OCC fulfils in instalments, so asking for 50
|
||||
* where 20 sit is a normal request.
|
||||
*/
|
||||
export function TransferRequestFormModal({
|
||||
opened,
|
||||
onClose,
|
||||
prefillFrom,
|
||||
onCreated,
|
||||
}: TransferRequestFormModalProps) {
|
||||
const { yardOptions, typeOptions } = useTransferOptions(opened);
|
||||
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||
const [quantity, setQuantity] = useState<number | string>(1);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
// Re-seed on every open so a carry-over never leaks into the next request.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setFromYardId(null); // always chosen fresh — that is the point of a re-ask
|
||||
setToYardId(prefillFrom?.toYardId ?? null);
|
||||
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
|
||||
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
|
||||
setReason(prefillFrom?.reason ?? "");
|
||||
}, [opened, prefillFrom]);
|
||||
|
||||
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
|
||||
|
||||
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
|
||||
const valid =
|
||||
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
|
||||
!sameYard &&
|
||||
Number(quantity) >= 1;
|
||||
|
||||
const submit = async () => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
await create.mutateAsync({
|
||||
fromYardId: fromYardId!,
|
||||
toYardId: toYardId!,
|
||||
wagonTypeId: wagonTypeId!,
|
||||
quantity: Number(quantity),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
toast.success("Transfer request filed");
|
||||
onClose();
|
||||
onCreated?.();
|
||||
} catch {
|
||||
// Server reason is surfaced by the http interceptor.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={prefillFrom ? "Request the rest from another yard" : "New transfer request"}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{prefillFrom ? (
|
||||
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
{yardLabel(prefillFrom.fromYard)} supplied{" "}
|
||||
{prefillFrom.fulfilledQuantity} of {prefillFrom.quantity}. Pick
|
||||
another yard to cover the remaining {outstandingOn(prefillFrom)}.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
label="Source yard"
|
||||
description={prefillFrom ? "Which yard should supply the rest" : undefined}
|
||||
placeholder="Where the wagons come from"
|
||||
data={yardOptions}
|
||||
value={fromYardId}
|
||||
onChange={setFromYardId}
|
||||
searchable
|
||||
required
|
||||
error={sameYard ? "Source and destination must differ" : undefined}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder="Where they are needed"
|
||||
data={yardOptions}
|
||||
value={toYardId}
|
||||
onChange={setToYardId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Type of wagon"
|
||||
data={typeOptions}
|
||||
value={wagonTypeId}
|
||||
onChange={setWagonTypeId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="How many"
|
||||
description="Can exceed what the yard holds today — OCC delivers in instalments"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={setQuantity}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why the wagons are needed"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
loading={create.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
File request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TransferCloseShortModalProps {
|
||||
request: WagonTransferRequest | null;
|
||||
onClose: () => void;
|
||||
onClosed?: (request: WagonTransferRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* End a request the source yard cannot finish. What already moved stays moved;
|
||||
* the requester is notified of the shortfall so they can raise it elsewhere.
|
||||
*/
|
||||
export function TransferCloseShortModal({
|
||||
request,
|
||||
onClose,
|
||||
onClosed,
|
||||
}: TransferCloseShortModalProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const closeShort = useMutation(
|
||||
api.wagonTransferRequests.closeShort.mutationOptions(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) setNote("");
|
||||
}, [request]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!request) return;
|
||||
try {
|
||||
await closeShort.mutateAsync({ id: request.id, note: note.trim() || undefined });
|
||||
toast.success("Request closed — the requester has been notified");
|
||||
onClose();
|
||||
onClosed?.(request);
|
||||
} catch {
|
||||
// Server reason surfaced by the http interceptor.
|
||||
}
|
||||
};
|
||||
|
||||
const outstanding = request ? outstandingOn(request) : 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(request)}
|
||||
onClose={onClose}
|
||||
radius="md"
|
||||
title="Close this request short"
|
||||
>
|
||||
{!request ? null : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{yardLabel(request.fromYard)} → {yardLabel(request.toYard)} ·{" "}
|
||||
{wagonTypeLabel(request.wagonType)}
|
||||
</Text>
|
||||
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
|
||||
{request.fulfilledQuantity} of {request.quantity} wagon(s) have been
|
||||
supplied. Closing leaves {outstanding} undelivered — the requester is
|
||||
told to ask another yard.
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Why can't the yard supply the rest?"
|
||||
description="Included in the requester's notification"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Keep it open
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={15} />}
|
||||
loading={closeShort.isPending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Close short
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
TransferRequestListFilter,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
import TransferFulfillModal from "./TransferFulfillModal";
|
||||
import {
|
||||
TransferCloseShortModal,
|
||||
TransferRequestFormModal,
|
||||
} from "./TransferRequestModals";
|
||||
import {
|
||||
TransferProgress,
|
||||
TransferStatusBadge,
|
||||
fmtDateTime,
|
||||
isOpenRequest,
|
||||
outstandingOn,
|
||||
wagonTypeLabel,
|
||||
yardLabel,
|
||||
} from "./wagon-transfer-ui";
|
||||
|
||||
const S = Freight.WagonTransferRequestStatus;
|
||||
|
||||
/** "Open" is the working set: nothing delivered yet OR part-delivered. */
|
||||
const OPEN_STATUSES = `${S.Pending},${S.PartiallyFulfilled}`;
|
||||
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: OPEN_STATUSES, label: "Open (awaiting wagons)" },
|
||||
{ value: S.Pending, label: "Not started" },
|
||||
{ value: S.PartiallyFulfilled, label: "Partly delivered" },
|
||||
{ value: S.Fulfilled, label: "Complete" },
|
||||
{ value: S.ClosedShort, label: "Closed short" },
|
||||
{ value: S.Cancelled, label: "Cancelled" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The wagon-transfer desk.
|
||||
*
|
||||
* A request is a count, not a wagon list: someone asks for 50 gondolas from
|
||||
* Dire Dawa, and OCC sends whatever that yard can spare, whenever it can. The
|
||||
* table is built around that — every row shows delivered-vs-asked, and a
|
||||
* request only leaves the queue when it is fully supplied or explicitly closed
|
||||
* short (which tells the requester to try another yard).
|
||||
*/
|
||||
export default function WagonTransfersPage() {
|
||||
const { user } = useAuth();
|
||||
const canRequest = hasPermission(user, FREIGHT_PERMS.wagons.transferRequest);
|
||||
const canFulfil = hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill);
|
||||
const canCloseShort =
|
||||
canFulfil || hasPermission(user, FREIGHT_PERMS.wagons.transferCloseShort);
|
||||
const canCancel =
|
||||
canRequest || hasPermission(user, FREIGHT_PERMS.wagons.transferCancel);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(OPEN_STATUSES);
|
||||
const [fromYardId, setFromYardId] = useState<string | null>(null);
|
||||
const [toYardId, setToYardId] = useState<string | null>(null);
|
||||
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [carryOver, setCarryOver] = useState<WagonTransferRequest | null>(null);
|
||||
const [fulfilling, setFulfilling] = useState<WagonTransferRequest | null>(null);
|
||||
const [closingShort, setClosingShort] = useState<WagonTransferRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const filter: TransferRequestListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(status ? { status } : {}),
|
||||
...(fromYardId ? { fromYardId } : {}),
|
||||
...(toYardId ? { toYardId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
status,
|
||||
fromYardId,
|
||||
toYardId,
|
||||
wagonTypeId,
|
||||
debouncedSearch,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.wagonTransferRequests.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
const rows = data?.items ?? [];
|
||||
const meta = data?.meta;
|
||||
|
||||
const { data: yards = [] } = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
|
||||
const yardOptions = yards.map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code ?? y.id,
|
||||
}));
|
||||
const typeOptions = wagonTypes.map((t) => ({
|
||||
value: t.id,
|
||||
label: [t.code, t.name].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatus(OPEN_STATUSES);
|
||||
setFromYardId(null);
|
||||
setToYardId(null);
|
||||
setWagonTypeId(null);
|
||||
setSearch("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<WagonTransferRequest>[] = [
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={13} className="shrink-0 opacity-60" />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: () => <span>Wagon type</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "progress",
|
||||
header: () => <span>Delivered</span>,
|
||||
cell: ({ row }) => <TransferProgress request={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "reason",
|
||||
header: () => <span>Reason</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed" lineClamp={2} maw={260}>
|
||||
{row.original.reason || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "filed",
|
||||
header: () => <span>Filed</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDateTime(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => <TransferStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const open = isOpenRequest(r);
|
||||
const short =
|
||||
r.status === S.ClosedShort && outstandingOn(r) > 0;
|
||||
return (
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
{open && canFulfil ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={13} />}
|
||||
onClick={() => setFulfilling(r)}
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
) : null}
|
||||
{open && r.fulfilledQuantity > 0 && canCloseShort ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<XCircle size={13} />}
|
||||
onClick={() => setClosingShort(r)}
|
||||
>
|
||||
Close short
|
||||
</Button>
|
||||
) : null}
|
||||
{short && canRequest ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Send size={13} />}
|
||||
onClick={() => {
|
||||
setCarryOver(r);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
Ask another yard
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === S.Pending && canCancel ? (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={cancel.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast.success("Request withdrawn");
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const openCount = rows.filter(isOpenRequest).length;
|
||||
const outstandingWagons = rows.reduce(
|
||||
(sum: number, r: WagonTransferRequest) =>
|
||||
sum + (isOpenRequest(r) ? outstandingOn(r) : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Wagon transfers"
|
||||
subtitle="Requests for wagons to move between yards — delivered in instalments until the full count is met"
|
||||
breadcrumbs={[
|
||||
{ label: "Wagons", href: "/dashboard/wagons" },
|
||||
{ label: "Transfers" },
|
||||
]}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{canRequest ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
onClick={() => {
|
||||
setCarryOver(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
New request
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Open on this page",
|
||||
value: openCount,
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
label: "Wagons still owed",
|
||||
value: outstandingWagons,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Requests matched",
|
||||
value: meta?.total ?? 0,
|
||||
icon: PackageCheck,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="requests" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="requests" leftSection={<Inbox size={15} />}>
|
||||
Requests
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={15} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="requests" pt="md">
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search the reason…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
w={240}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={STATUS_FILTER_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
w={200}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="From yard"
|
||||
data={yardOptions}
|
||||
value={fromYardId}
|
||||
onChange={(v) => {
|
||||
setFromYardId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="To yard"
|
||||
data={yardOptions}
|
||||
value={toYardId}
|
||||
onChange={(v) => {
|
||||
setToYardId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Wagon type"
|
||||
data={typeOptions}
|
||||
value={wagonTypeId}
|
||||
onChange={(v) => {
|
||||
setWagonTypeId(v);
|
||||
resetPage();
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
w={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Button variant="subtle" radius="md" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
totalCount: meta?.total ?? 0,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount: meta?.totalPages ?? 1,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<TransferHistoryPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<TransferRequestFormModal
|
||||
opened={formOpen}
|
||||
prefillFrom={carryOver}
|
||||
onClose={() => {
|
||||
setFormOpen(false);
|
||||
setCarryOver(null);
|
||||
}}
|
||||
onCreated={() => void refetch()}
|
||||
/>
|
||||
<TransferFulfillModal
|
||||
request={fulfilling}
|
||||
onClose={() => setFulfilling(null)}
|
||||
onDone={() => void refetch()}
|
||||
/>
|
||||
<TransferCloseShortModal
|
||||
request={closingShort}
|
||||
onClose={() => setClosingShort(null)}
|
||||
onClosed={(r) => {
|
||||
void refetch();
|
||||
// Straight into the re-ask: the shortfall is the whole reason this
|
||||
// request was closed, so offer the other-yard form immediately.
|
||||
if (canRequest) {
|
||||
setCarryOver(r);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who moved what. A staffer sees their own activity; holders of
|
||||
* `transfer_history_all` can widen it to every staffer (the backend enforces
|
||||
* the scope regardless of the toggle).
|
||||
*/
|
||||
function TransferHistoryPanel() {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({
|
||||
input: { page, pageSize: 20 },
|
||||
}),
|
||||
enabled: scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements = source.data?.movements ?? [];
|
||||
const meta = source.data?.meta;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Text fw={600}>Transfer history</Text>
|
||||
{canSeeAll ? (
|
||||
<Switch
|
||||
label="All staff"
|
||||
checked={allStaff}
|
||||
onChange={(e) => {
|
||||
setAllStaff(e.currentTarget.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<Group align="flex-start" grow gap="lg" wrap="wrap">
|
||||
<Stack gap={6} miw={280}>
|
||||
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||
Requests ({meta?.requestsTotal ?? 0})
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing yet.
|
||||
</Text>
|
||||
) : (
|
||||
requests.map((r) => (
|
||||
<Group key={r.id} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{yardLabel(r.fromYard)} → {yardLabel(r.toYard)} ·{" "}
|
||||
{r.fulfilledQuantity}/{r.quantity}
|
||||
</Text>
|
||||
<TransferStatusBadge status={r.status} />
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={6} miw={280}>
|
||||
<Text size="sm" fw={700} c="dimmed" tt="uppercase">
|
||||
Wagons moved ({meta?.movementsTotal ?? 0})
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing yet.
|
||||
</Text>
|
||||
) : (
|
||||
movements.map((m) => (
|
||||
<Group key={m.id} gap={8} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{m.wagon?.wagonNumber ?? "Wagon"} · {yardLabel(m.fromYard)} →{" "}
|
||||
{yardLabel(m.toYard)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="center" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Page {meta?.page ?? page} of {meta?.totalPages ?? 1}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
disabled={page >= (meta?.totalPages ?? 1)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { WagonTransferRequest } from "@/services/wagon.service";
|
||||
|
||||
export const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
|
||||
export const wagonTypeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? [t.code, t.name].filter(Boolean).join(" · ") : "—";
|
||||
|
||||
/** Wagons still owed on a request (0 once it is complete or closed). */
|
||||
export const outstandingOn = (r: WagonTransferRequest): number =>
|
||||
Math.max(0, r.quantity - (r.fulfilledQuantity ?? 0));
|
||||
|
||||
/** A request OCC can still move wagons against. */
|
||||
export const isOpenRequest = (r: WagonTransferRequest): boolean =>
|
||||
r.status === Freight.WagonTransferRequestStatus.Pending ||
|
||||
r.status === Freight.WagonTransferRequestStatus.PartiallyFulfilled;
|
||||
|
||||
export const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: "Awaiting wagons", color: "gray" },
|
||||
PARTIALLY_FULFILLED: { label: "Partly delivered", color: "yellow" },
|
||||
FULFILLED: { label: "Complete", color: "teal" },
|
||||
CLOSED_SHORT: { label: "Closed short", color: "orange" },
|
||||
CANCELLED: { label: "Cancelled", color: "red" },
|
||||
};
|
||||
|
||||
export function TransferStatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_META[status] ?? { label: status, color: "gray" };
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivered-vs-asked bar. The number is what staff actually need — the bar just
|
||||
* makes "nearly there" vs "barely started" readable at a glance across a page
|
||||
* of requests.
|
||||
*/
|
||||
export function TransferProgress({ request }: { request: WagonTransferRequest }) {
|
||||
const delivered = request.fulfilledQuantity ?? 0;
|
||||
const percent = request.quantity > 0 ? (delivered / request.quantity) * 100 : 0;
|
||||
const outstanding = outstandingOn(request);
|
||||
const complete = delivered >= request.quantity;
|
||||
const closedShort =
|
||||
request.status === Freight.WagonTransferRequestStatus.ClosedShort;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
complete
|
||||
? "Fully supplied"
|
||||
: closedShort
|
||||
? `Closed ${outstanding} wagon(s) short`
|
||||
: `${outstanding} wagon(s) still to come`
|
||||
}
|
||||
>
|
||||
<Box miw={110}>
|
||||
<Group gap={6} justify="space-between" wrap="nowrap" mb={4}>
|
||||
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{delivered} / {request.quantity}
|
||||
</Text>
|
||||
{!complete && !closedShort ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{outstanding} left
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
color={complete ? "teal" : closedShort ? "orange" : "yellow"}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const fmtDateTime = (iso?: string | null) =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "—";
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -208,6 +210,7 @@ import {
|
||||
type CreateTransferRequestPayload,
|
||||
type BulkFulfillResult,
|
||||
type TransferHistory,
|
||||
type TransferRequestListFilter,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -1719,14 +1722,14 @@ export const api = {
|
||||
|
||||
wagonTransferRequests: {
|
||||
list: endpoint<
|
||||
{ status?: WagonTransferRequest["status"] },
|
||||
WagonTransferRequest[]
|
||||
{ filter?: TransferRequestListFilter },
|
||||
PaginatedResponse<WagonTransferRequest>
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"list",
|
||||
({ status }) =>
|
||||
wagonTransferRequestService.list(status).then((r) => r.data),
|
||||
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
|
||||
({ filter }) =>
|
||||
wagonTransferRequestService.list(filter).then((r) => r.data),
|
||||
({ filter }) => ["wagonTransferRequests", "list", filter ?? {}],
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, WagonTransferRequest>(
|
||||
@@ -1774,19 +1777,47 @@ export const api = {
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
history: endpoint<void, TransferHistory>(
|
||||
closeShort: endpoint<{ id: string; note?: string }, WagonTransferRequest>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
||||
() => ["wagonTransferRequests", "history", "mine"],
|
||||
"closeShort",
|
||||
({ id, note }) =>
|
||||
wagonTransferRequestService.closeShort(id, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
||||
history: endpoint<{ page?: number; pageSize?: number }, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
({ page, pageSize }) =>
|
||||
wagonTransferRequestService.myHistory(page, pageSize).then((r) => r.data),
|
||||
({ page, pageSize }) => [
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
"mine",
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
],
|
||||
),
|
||||
|
||||
historyAll: endpoint<
|
||||
{ userId?: string; page?: number; pageSize?: number },
|
||||
TransferHistory
|
||||
>(
|
||||
"wagonTransferRequests",
|
||||
"historyAll",
|
||||
({ userId }) =>
|
||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
||||
({ userId, page, pageSize }) =>
|
||||
wagonTransferRequestService
|
||||
.allHistory(userId, page, pageSize)
|
||||
.then((r) => r.data),
|
||||
({ userId, page, pageSize }) => [
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
"all",
|
||||
userId ?? "",
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
],
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
@@ -390,11 +390,12 @@ export const bookingsService = {
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate?: string,
|
||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||
form.append("doCollectedDate", dates.doCollectedDate);
|
||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
@@ -126,6 +126,23 @@ export interface SignContractPayload {
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stored version of a clearance document. The current row plus every
|
||||
* superseded upload — staff corrections never erase what the customer sent.
|
||||
*/
|
||||
export interface ClearanceDocumentVersion {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
uploadedAt: string;
|
||||
isCurrent: boolean;
|
||||
replacedAt: string | null;
|
||||
replacedByUserId: string | null;
|
||||
replaceReason: string | null;
|
||||
}
|
||||
|
||||
async function postContract<T>(url: string, body?: unknown): Promise<T> {
|
||||
const response = await client.post<T>(url, body ?? {});
|
||||
return unwrap(response.data);
|
||||
@@ -297,6 +314,50 @@ export const contractsService = {
|
||||
) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
|
||||
|
||||
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
||||
requestTransitAssignee: (id: string, note?: string) =>
|
||||
postContract<Freight.IContract>(
|
||||
C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id),
|
||||
{ note },
|
||||
),
|
||||
|
||||
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
|
||||
assignTransitAssignee: (id: string, assignee: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
||||
assignee,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Replace a clearance document in place. The customer's original is retired
|
||||
* into the version history rather than overwritten, and the new file comes
|
||||
* back unreviewed so it still has to be approved.
|
||||
*/
|
||||
replaceClearanceDocument: async (
|
||||
id: string,
|
||||
fileKey: string,
|
||||
file: File,
|
||||
reason: string,
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("reason", reason);
|
||||
const response = await client.post(
|
||||
C.CLEARANCE_DOC_REPLACE(id, fileKey),
|
||||
form,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
/** Every stored version of one clearance document, newest first. */
|
||||
getClearanceDocumentVersions: async (
|
||||
id: string,
|
||||
fileKey: string,
|
||||
): Promise<ClearanceDocumentVersion[]> => {
|
||||
const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey));
|
||||
return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? [];
|
||||
},
|
||||
|
||||
uploadClearanceOutput: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
@@ -390,11 +451,12 @@ export const contractsService = {
|
||||
uploadDeliveryOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate?: string,
|
||||
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
||||
form.append("doCollectedDate", dates.doCollectedDate);
|
||||
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
@@ -109,10 +109,15 @@ export interface WagonTransferRequest {
|
||||
toYardId: string;
|
||||
wagonTypeId: string;
|
||||
quantity: number;
|
||||
/** How many have actually moved so far — OCC delivers in instalments. */
|
||||
fulfilledQuantity: number;
|
||||
status: Freight.WagonTransferRequestStatus;
|
||||
requestedByUserId: string | null;
|
||||
fulfilledByUserId: string | null;
|
||||
/** When the LAST instalment ran, not necessarily the full count. */
|
||||
fulfilledAt: string | null;
|
||||
closedShortAt?: string | null;
|
||||
closedShortByUserId?: string | null;
|
||||
/** Why the wagons are needed — required for new requests, shown on the queue. */
|
||||
reason?: string | null;
|
||||
note: string | null;
|
||||
@@ -132,7 +137,7 @@ export interface CreateTransferRequestPayload {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Bulk accept-and-execute result: what ran, what stayed PENDING and why. */
|
||||
/** Bulk accept-and-execute result: what ran, what stayed open and why. */
|
||||
export interface BulkFulfillResult {
|
||||
fulfilled: WagonTransferRequest[];
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
@@ -142,20 +147,52 @@ export interface BulkFulfillResult {
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovementRecord[];
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
requestsTotal: number;
|
||||
movementsTotal: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Desk list filters — `status` may be a comma-separated set ("open" tab). */
|
||||
export interface TransferRequestListFilter {
|
||||
status?: string;
|
||||
fromYardId?: string;
|
||||
toYardId?: string;
|
||||
wagonTypeId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const listQuery = (filter: TransferRequestListFilter = {}): string => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
};
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
list: (filter: TransferRequestListFilter = {}) =>
|
||||
apiClient.get<PaginatedResponse<WagonTransferRequest>>(
|
||||
`/wagon-transfer-requests${listQuery(filter)}`,
|
||||
),
|
||||
/** The caller's own history (both roles: requests they filed and fulfilled). */
|
||||
myHistory: () =>
|
||||
apiClient.get<TransferHistory>('/wagon-transfer-requests/history'),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string) =>
|
||||
myHistory: (page = 1, pageSize = 20) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
||||
`/wagon-transfer-requests/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
/** Admin: any/all staff's history (optional userId filter). */
|
||||
allHistory: (userId?: string, page = 1, pageSize = 20) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all?page=${page}&pageSize=${pageSize}` +
|
||||
`${userId ? `&userId=${userId}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
@@ -176,4 +213,10 @@ export const wagonTransferRequestService = {
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/cancel`,
|
||||
),
|
||||
/** OCC: end the request short — the source yard has no more to give. */
|
||||
closeShort: (id: string, note?: string) =>
|
||||
apiClient.post<WagonTransferRequest>(
|
||||
`/wagon-transfer-requests/${id}/close-short`,
|
||||
{ note },
|
||||
),
|
||||
};
|
||||
|
||||
@@ -616,6 +616,12 @@ export interface TrainScheduleDetail {
|
||||
wagons: Array<{
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
/**
|
||||
* Place in the drawn consist, 1..n — the built train's real coupling
|
||||
* order (reversed for a reverseWagonOrder schedule). Label wagons with
|
||||
* this, not sequenceNo: a slot's stored sequenceNo is not its position.
|
||||
*/
|
||||
position?: number;
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
assignedWeightTons: number;
|
||||
|
||||
Reference in New Issue
Block a user