mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
train
This commit is contained in:
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestIdParam = searchParams.get("requestId");
|
||||
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
|
||||
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
|
||||
const copyFromParam = searchParams.get("copyFrom");
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
// The expired booking a Rebook is copying from (its cargo seeds the form).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
queryKey: ["rebook-copy-from", copyFromParam],
|
||||
queryFn: () => bookingsService.getById(copyFromParam!),
|
||||
enabled: Boolean(copyFromParam),
|
||||
});
|
||||
|
||||
// Same window-gating the customer sees: booking is only allowed while a
|
||||
// window is OPEN for one of the contract's routes. Intercity contracts are
|
||||
// never window-gated — the shipment rides a passing train staff pick later.
|
||||
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
|
||||
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
|
||||
// item count isn't on the booking payload yet, so bulk rebooks fall through to
|
||||
// the normal contract seed and GL re-enters the quantity.)
|
||||
useEffect(() => {
|
||||
if (!copyFromBooking || prefilled) return;
|
||||
const lines = copyFromBooking.bookingContainers ?? [];
|
||||
if (!lines.length) return;
|
||||
setPrefilled(true);
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [copyFromBooking, prefilled]);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||
// portal form does. Subsequent renders reuse the lines.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit — every
|
||||
* coupled locomotive and wagon follows, so their current yards always match
|
||||
* the train's.
|
||||
*/
|
||||
export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [yardId, setYardId] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setYardId(composition?.currentYard?.id ?? "");
|
||||
}, [opened, composition]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition || !yardId) return;
|
||||
try {
|
||||
await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
|
||||
toast({ title: "Train relocated" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Relocation failed",
|
||||
description: parseError(err, "Could not change the yard"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const memberCount =
|
||||
(composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Change yard — train {composition?.code}</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" icon={<MapPin size={16} />}>
|
||||
The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
|
||||
locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
|
||||
{composition?.totals.wagonCount ?? 0} wagon
|
||||
{(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
|
||||
are relocated so their current yard always matches the train's. Wagon moves are
|
||||
recorded in the movement ledger.
|
||||
</Alert>
|
||||
<Select
|
||||
label="New yard"
|
||||
placeholder="Select yard"
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={yardId || null}
|
||||
onChange={(v) => setYardId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={setYard.isPending}
|
||||
disabled={!yardId || yardId === composition?.currentYard?.id}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Relocate train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeYardModalProps {
|
||||
composition: TrainComposition | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Train as TrainIcon } from "lucide-react";
|
||||
|
||||
import type {
|
||||
TrainCompositionLocomotive,
|
||||
TrainCompositionWagon,
|
||||
} from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
|
||||
* train would leave the yard. Scrolls horizontally for long consists.
|
||||
*/
|
||||
export default function TrainConsistStrip({
|
||||
locomotives,
|
||||
wagons,
|
||||
emptyHint = "No wagons attached yet — add wagons from the yard below.",
|
||||
}: TrainConsistStripProps) {
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="lg"
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
borderRadius: 12,
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box style={{ display: "inline-block", minWidth: "100%" }}>
|
||||
<Group gap={0} wrap="nowrap" align="flex-end">
|
||||
{locomotives.map((loco, index) => (
|
||||
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
{index > 0 ? <Coupler /> : null}
|
||||
<LocomotiveCar locomotive={loco} />
|
||||
</Group>
|
||||
))}
|
||||
{wagons.map((wagon) => (
|
||||
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
|
||||
<Coupler />
|
||||
<WagonCar wagon={wagon} />
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
{/* The rail */}
|
||||
<Box
|
||||
mt={6}
|
||||
style={{
|
||||
height: 0,
|
||||
borderTop: "3px solid var(--mantine-color-gray-4)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
/>
|
||||
{!wagons.length ? (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrainConsistStripProps {
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
emptyHint?: string;
|
||||
}
|
||||
|
||||
function Coupler() {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: 12,
|
||||
height: 4,
|
||||
marginBottom: 18,
|
||||
background: "var(--mantine-color-gray-5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${locomotive.code}${locomotive.name ? ` — ${locomotive.name}` : ""} · ${
|
||||
locomotive.role === "LEAD" ? "Lead" : "Assist"
|
||||
} · pulls ${locomotive.maxPullWeightTons}T`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="sm"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 96,
|
||||
borderRadius: "10px 14px 4px 4px",
|
||||
background:
|
||||
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
|
||||
color: "white",
|
||||
border: "1px solid var(--mantine-color-edr-green-9)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<TrainIcon size={13} />
|
||||
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
|
||||
{locomotive.code}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
|
||||
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${wagon.wagonNumber}${
|
||||
wagon.wagonType
|
||||
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
: ""
|
||||
}`}
|
||||
withArrow
|
||||
>
|
||||
<Stack
|
||||
gap={2}
|
||||
align="center"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{
|
||||
minWidth: 76,
|
||||
borderRadius: 6,
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
|
||||
flexShrink: 0,
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
#{wagon.sequenceNumber ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed" lh={1}>
|
||||
{wagon.wagonType?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -506,12 +506,19 @@ function TrackBed() {
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
locomotives,
|
||||
wagons,
|
||||
freightType,
|
||||
trainNumber,
|
||||
totalLengthMeters,
|
||||
}: {
|
||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||
locomotives?: Array<{
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
maxPullWeightTons?: number | null;
|
||||
}> | null;
|
||||
wagons: DiagramWagonInput[];
|
||||
freightType?: string | null;
|
||||
trainNumber?: string | null;
|
||||
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
|
||||
}) {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const locos = useMemo(
|
||||
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
|
||||
[locomotives, locomotive],
|
||||
);
|
||||
|
||||
const normalized = useMemo(
|
||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||
[wagons, freightType],
|
||||
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
// Weakest locomotive caps the set — same rule the allocation engine applies.
|
||||
const pullLimits = locos
|
||||
.map((l) => Number(l.maxPullWeightTons))
|
||||
.filter((v) => Number.isFinite(v) && v > 0);
|
||||
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
pullLimit,
|
||||
pullUtil: pullLimit
|
||||
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
}, [normalized, locos]);
|
||||
|
||||
// cars-per-row from measured width; locomotive counts as one car
|
||||
// cars-per-row from measured width; each locomotive counts as one car
|
||||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||
const cars = useMemo(
|
||||
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
||||
[normalized],
|
||||
() => [
|
||||
...locos.map((l) => ({ kind: "loco" as const, l })),
|
||||
...normalized.map((w) => ({ kind: "wagon" as const, w })),
|
||||
],
|
||||
[locos, normalized],
|
||||
);
|
||||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||
|
||||
if (!locomotive && !wagons.length) return null;
|
||||
if (!locos.length && !wagons.length) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
|
||||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||
{carIndex > 0 ? <Coupler /> : null}
|
||||
{car.kind === "loco" ? (
|
||||
locomotive ? (
|
||||
<LocomotiveCar
|
||||
code={locomotive.code ?? "LOCO"}
|
||||
name={locomotive.name}
|
||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
||||
/>
|
||||
) : null
|
||||
<LocomotiveCar
|
||||
code={car.l.code ?? "LOCO"}
|
||||
name={car.l.name}
|
||||
maxPullWeightTons={car.l.maxPullWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<WagonCar wagon={car.w} />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
@@ -25,8 +28,13 @@ import {
|
||||
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 { WagonTransferRequest } from "@/services/wagon.service";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
</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>
|
||||
</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());
|
||||
|
||||
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
|
||||
</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 ? (
|
||||
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -75,6 +82,8 @@ interface ClearanceRow {
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||
routeStops: string[];
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
@@ -93,6 +102,25 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the contract's ordered route legs into one corridor of stops —
|
||||
* origin of the first leg, then each leg's destination (Djibouti → Adama →
|
||||
* Dire Dawa). A leg whose origin differs from the previous destination inserts
|
||||
* that stop too, so gapped route lists stay readable.
|
||||
*/
|
||||
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
|
||||
const stops: string[] = [];
|
||||
for (const r of routes) {
|
||||
const origin = yardLabel(r.originYard);
|
||||
const destination = yardLabel(r.destinationYard);
|
||||
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
|
||||
stops.push(origin);
|
||||
}
|
||||
stops.push(destination);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
routeStops: contractRouteStops(routes),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{(r.routeStops.length >= 2
|
||||
? r.routeStops
|
||||
: [r.originLabel, r.destinationLabel]
|
||||
).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<Text size="sm" fw={500}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onRebook,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onRebook: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
|
||||
!r.bookingCreated &&
|
||||
r.status === "CLEARANCE_READY";
|
||||
|
||||
// A customs shipment whose booking lost its slot — GL rebooks it (customer
|
||||
// can't self-rebook a customs booking). Copies the expired booking's cargo.
|
||||
const isRebookable = (r: ShipmentBookingRow) =>
|
||||
canCreateBooking &&
|
||||
Boolean(r.contractId) &&
|
||||
r.customs &&
|
||||
r.status === "EXPIRED";
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
const rebookable = isRebookable(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook (GL)
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
Replace,
|
||||
Ruler,
|
||||
@@ -29,9 +30,10 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -62,6 +64,7 @@ export default function TrainBuilderDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [locoModalOpen, setLocoModalOpen] = useState(false);
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
@@ -144,6 +147,13 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
@@ -162,8 +172,8 @@ export default function TrainBuilderDetailPage() {
|
||||
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
|
||||
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
|
||||
{
|
||||
label: "Max gross / haul limit",
|
||||
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
|
||||
label: "Payload available",
|
||||
value: `${totals.payloadCapacityTons}T of ${totals.maxPullWeightTons}T`,
|
||||
icon: Weight,
|
||||
},
|
||||
{
|
||||
@@ -180,33 +190,40 @@ export default function TrainBuilderDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600}>Consist</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{composition.locomotives.length} locomotive
|
||||
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
|
||||
{totals.wagonCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
<TrainConsistStrip
|
||||
locomotives={composition.locomotives}
|
||||
wagons={composition.wagons}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
<Card>
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar
|
||||
label="Weight utilization (fully loaded)"
|
||||
pct={totals.weightUtilizationPct}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
The real weight check happens at allocation: booked cargo weight plus
|
||||
wagon tare (gross) must stay within the locomotives' haul limit.
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setLocoModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ChangeYardModal
|
||||
composition={composition}
|
||||
opened={yardModalOpen}
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={disbandOpen}
|
||||
onClose={() => setDisbandOpen(false)}
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
|
||||
{row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
|
||||
{row.original.totalLengthMeters}m
|
||||
</Text>
|
||||
),
|
||||
|
||||
@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
|
||||
}).format(new Date(data.scheduleDate)) + " EAT"
|
||||
: "No date"}
|
||||
</HeroChip>
|
||||
{data.train ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Train {data.train.code}
|
||||
{data.train.trainName ? ` — ${data.train.trainName}` : ""}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
{data.locomotive ? (
|
||||
<HeroChip icon={<TrainFront size={12} />}>
|
||||
Loco {data.locomotive.code} ·{" "}
|
||||
|
||||
@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Stack gap="md">
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
locomotives={locomotives}
|
||||
wagons={
|
||||
schedule.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.trainNumber}
|
||||
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
|
||||
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
<Paper
|
||||
@@ -1003,6 +1004,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
...(schedule.train
|
||||
? [
|
||||
{
|
||||
label: "Train",
|
||||
value: schedule.train.code,
|
||||
hint: schedule.train.trainName ?? "Built train (Train Builder)",
|
||||
icon: Train,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: locomotives.length > 1 ? "Locomotives" : "Locomotive",
|
||||
value: locomotives.length
|
||||
|
||||
@@ -200,6 +200,7 @@ import {
|
||||
type WagonMovementRecord,
|
||||
type WagonTransferRequest,
|
||||
type CreateTransferRequestPayload,
|
||||
type TransferHistory,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -1701,6 +1702,21 @@ export const api = {
|
||||
undefined,
|
||||
() => [["wagonTransferRequests"]],
|
||||
),
|
||||
|
||||
history: endpoint<void, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"history",
|
||||
() => wagonTransferRequestService.myHistory().then((r) => r.data),
|
||||
() => ["wagonTransferRequests", "history", "mine"],
|
||||
),
|
||||
|
||||
historyAll: endpoint<{ userId?: string }, TransferHistory>(
|
||||
"wagonTransferRequests",
|
||||
"historyAll",
|
||||
({ userId }) =>
|
||||
wagonTransferRequestService.allHistory(userId).then((r) => r.data),
|
||||
({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
|
||||
),
|
||||
},
|
||||
|
||||
trains: {
|
||||
@@ -1781,6 +1797,15 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
|
||||
"train-builder",
|
||||
"setYard",
|
||||
({ id, currentYardId }) =>
|
||||
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"assignWagons",
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
}
|
||||
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
|
||||
export interface TrainCompositionTotals {
|
||||
wagonCount: number;
|
||||
totalTareTons: number;
|
||||
/** Informational only — building never checks against full capacity. */
|
||||
totalCapacityTons: number;
|
||||
maxGrossTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
weightUtilizationPct: number | null;
|
||||
/** Cargo the locomotives can still haul once pulling the empty consist. */
|
||||
payloadCapacityTons: number;
|
||||
/** Share of the haul limit consumed by the empty wagons alone. */
|
||||
tareUtilizationPct: number | null;
|
||||
lengthUtilizationPct: number | null;
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ export interface AvailableTrain {
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
wagonCount: number;
|
||||
maxGrossTons: number;
|
||||
totalTareTons: number;
|
||||
totalLengthMeters: number;
|
||||
maxPullWeightTons: number;
|
||||
atOriginYard: boolean;
|
||||
@@ -157,6 +160,9 @@ export const trainBuilderService = {
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
setLocomotives: (id: string, locomotiveIds: string[]) =>
|
||||
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
|
||||
/** Relocate the train — coupled locomotives and wagons move with it. */
|
||||
setYard: (id: string, currentYardId: string) =>
|
||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/yard`, { currentYardId }),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
|
||||
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
|
||||
bookingId: string | null;
|
||||
kind: Freight.WagonMovementKind;
|
||||
movedByUserId: string | null;
|
||||
/** The transfer request this move fulfilled, when one drove it. */
|
||||
transferRequestId: string | null;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
wagon?: { id: string; wagonNumber?: string } | null;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovementRecord[];
|
||||
}
|
||||
|
||||
export const wagonTransferRequestService = {
|
||||
list: (status?: Freight.WagonTransferRequestStatus) =>
|
||||
apiClient.get<WagonTransferRequest[]>(
|
||||
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
/** 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) =>
|
||||
apiClient.get<TransferHistory>(
|
||||
`/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
|
||||
),
|
||||
getById: (id: string) =>
|
||||
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
|
||||
create: (data: CreateTransferRequestPayload) =>
|
||||
|
||||
@@ -309,6 +309,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -417,6 +423,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train?: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName?: string | null;
|
||||
} | null;
|
||||
direction?: string | null;
|
||||
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
||||
requiresLoadingConfirmation?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user