mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge pull request #963 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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,
|
||||
})
|
||||
: "—";
|
||||
Reference in New Issue
Block a user