mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
changes
This commit is contained in:
@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
|
||||
EXPIRED: "red",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "cyan",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -630,6 +630,20 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile && (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file!.id, true)}
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -727,9 +727,9 @@ function ImportT1UploadStep({
|
||||
);
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
|
||||
// closes/accepts the T1.
|
||||
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
</Alert>
|
||||
) : uploaded.length === 0 && !canUpload ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
|
||||
@@ -177,6 +177,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
APPROVED: "cyan",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "blue",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -33,7 +33,9 @@ const FleetRecordActions = ({
|
||||
const isVehicle = config.slug === "vehicles";
|
||||
const showHistory =
|
||||
Boolean(onHistory) &&
|
||||
(config.slug === "drivers" || config.slug === "vehicles");
|
||||
(config.slug === "drivers" ||
|
||||
config.slug === "vehicles" ||
|
||||
config.slug === "wagons");
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import type { WagonMovementRecord } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonMovementHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: FleetRecord | null;
|
||||
}
|
||||
|
||||
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Chip style per wagon_movements ledger kind. */
|
||||
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
|
||||
LOADED: {
|
||||
label: "Loaded leg",
|
||||
color: "edr-green",
|
||||
icon: <PackageCheck size={14} />,
|
||||
},
|
||||
EMPTY_REPOSITION: {
|
||||
label: "Empty reposition",
|
||||
color: "blue",
|
||||
icon: <TrainFront size={14} />,
|
||||
},
|
||||
MANUAL: {
|
||||
label: "Manual move",
|
||||
color: "orange",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
const yardLabel = (
|
||||
yard: { label?: string; code?: string } | null | undefined,
|
||||
yardId: string | null,
|
||||
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
|
||||
|
||||
const fmt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Movement ledger for one wagon: every relocation between yards — booking legs,
|
||||
* empty reposition rides, and manual staff corrections — newest first.
|
||||
*/
|
||||
const WagonMovementHistoryModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
record,
|
||||
}: WagonMovementHistoryModalProps) => {
|
||||
const r = asObj(record);
|
||||
const id = r.id ? String(r.id) : "";
|
||||
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
api.wagons.movements.queryOptions({
|
||||
input: { id },
|
||||
enabled: opened && Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const movements: WagonMovementRecord[] = data ?? [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
|
||||
radius="lg"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : movements.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No movements recorded yet. Every yard-to-yard move appears here — a
|
||||
booking's loaded leg, an empty reposition ride, or a manual correction.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
|
||||
{movements.map((movement) => {
|
||||
const meta = KIND_META[movement.kind] ?? {
|
||||
label: movement.kind,
|
||||
color: "gray",
|
||||
icon: <TrainFront size={14} />,
|
||||
};
|
||||
const from = yardLabel(movement.fromYard, movement.fromYardId);
|
||||
const to = yardLabel(movement.toYard, movement.toYardId);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={movement.id}
|
||||
bullet={meta.icon}
|
||||
title={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{movement.note && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{movement.note}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{fmt(movement.occurredAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonMovementHistoryModal;
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCell({ row }: { row: YardWorkBookingRow }) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkTable({
|
||||
rows,
|
||||
side,
|
||||
trainHere,
|
||||
onLoad,
|
||||
onUnload,
|
||||
pendingBookingId,
|
||||
}: {
|
||||
rows: YardWorkBookingRow[];
|
||||
side: "load" | "unload";
|
||||
trainHere: boolean;
|
||||
onLoad: (bookingId: string) => void;
|
||||
onUnload: (bookingId: string) => void;
|
||||
pendingBookingId: string | null;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{side === "load" ? "No bookings board here." : "No bookings alight here."}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
|
||||
const canAct = side === "load" ? row.canLoad : row.canUnload;
|
||||
return (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<BookingCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{timestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(timestamp)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{side === "load" ? (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo loaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.loadedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onLoad(row.id)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo unloaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.arrivedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onUnload(row.id)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard load/unload worklist for one schedule — every trade direction. Each
|
||||
* booking boards at its origin yard and alights at its destination yard; the
|
||||
* operator confirms both while the train's last recorded checkpoint is at that
|
||||
* yard (the server validates the position). Unloading stamps the booking's own
|
||||
* arrival — ARRIVED for import/export, COMPLETED for intercity.
|
||||
*/
|
||||
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Cargo loaded" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Load failed",
|
||||
description: parseError(err, "Could not confirm loading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadScheduleBooking.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
void invalidate();
|
||||
toast({
|
||||
title:
|
||||
result.status === "COMPLETED"
|
||||
? "Cargo unloaded — booking completed"
|
||||
: "Cargo unloaded — booking arrived",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Unload failed",
|
||||
description: parseError(err, "Could not confirm unloading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const data = yardWorkQuery.data;
|
||||
const yards: YardWorkYard[] = data?.yards ?? [];
|
||||
const trainAtYardId = data?.trainAtYardId ?? null;
|
||||
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
|
||||
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<MapPin size={18} />
|
||||
<Text fw={700}>Yard load / unload</Text>
|
||||
</Group>
|
||||
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading yard worklists…
|
||||
</Text>
|
||||
</Group>
|
||||
) : yardWorkQuery.isError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
|
||||
</Alert>
|
||||
) : yards.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings are assigned to this schedule yet.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
What boards and alights at each stop. Confirm loading at a booking's
|
||||
origin and unloading at its destination while the train is at that
|
||||
yard — unloading stamps the booking's own arrival, even before the
|
||||
train's final stop.
|
||||
</Text>
|
||||
{yards.map((yard, index) => {
|
||||
const trainHere = trainAtYardId === yard.yardId;
|
||||
return (
|
||||
<Stack key={yard.yardId} gap="sm">
|
||||
{index > 0 && <Divider />}
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{yard.yard}</Text>
|
||||
{trainHere && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={12} />}
|
||||
>
|
||||
Train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Board here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toLoad}
|
||||
side="load"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingLoadId}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Alight here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toUnload}
|
||||
side="unload"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingUnloadId}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -326,6 +326,11 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
|
||||
BOOKING_LOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
||||
INTERCITY_CANDIDATES: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
INTERCITY_ACCEPT: (id: string) =>
|
||||
|
||||
@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "In Transit",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
ARRIVED: {
|
||||
label: "Arrived",
|
||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completed",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
@@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-sky-600",
|
||||
stage: 4,
|
||||
},
|
||||
ARRIVED: {
|
||||
title: "Arrived",
|
||||
description: "Cargo unloaded at its destination yard.",
|
||||
color: "text-emerald-600",
|
||||
stage: 4,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Completed",
|
||||
description: "Booking fulfilled.",
|
||||
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
record={historyTarget}
|
||||
/>
|
||||
) : (
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
|
||||
{ label: "Both", value: "BOTH" },
|
||||
];
|
||||
|
||||
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
|
||||
const YARD_COUNTRIES = [
|
||||
{ label: "Ethiopia", value: "Ethiopia" },
|
||||
{ label: "Djibouti", value: "Djibouti" },
|
||||
];
|
||||
|
||||
const APPROVAL_ROLES = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
@@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "country", label: "Country", type: "text", required: true },
|
||||
{
|
||||
name: "country",
|
||||
label: "Country",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: YARD_COUNTRIES,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
|
||||
@@ -180,6 +180,7 @@ import {
|
||||
wagonService,
|
||||
type Wagon,
|
||||
type WagonListFilters,
|
||||
type WagonMovementRecord,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -593,6 +594,40 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
yardWork: endpoint<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").YardWorkResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"yard-work",
|
||||
({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId),
|
||||
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
|
||||
),
|
||||
|
||||
loadScheduleBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
import("@/types/trainScheduling").BookingLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-load",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.loadScheduleBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
unloadScheduleBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
import("@/types/trainScheduling").BookingUnloadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-unload",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
intercityCandidates: endpoint<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||
@@ -1493,6 +1528,13 @@ export const api = {
|
||||
wagonService.getById(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
movements: endpoint<{ id: string }, WagonMovementRecord[]>(
|
||||
"wagons",
|
||||
"movements",
|
||||
({ id }) => wagonService.getMovements(id).then((r) => r.data),
|
||||
({ id }) => ["wagons", "movements", id],
|
||||
),
|
||||
|
||||
assignToTrain: endpoint<
|
||||
{ wagonId: string; trainId: string; sequenceNumber?: number },
|
||||
Wagon
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
BookingLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -35,6 +37,7 @@ import type {
|
||||
UploadImportDjiboutiDocumentPayload,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
YardWorkResult,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface BookingReferenceDataResponse {
|
||||
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getYardWork: async (scheduleId: string): Promise<YardWorkResult> => {
|
||||
const response = await client.get<YardWorkResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadScheduleBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<BookingLoadResult> => {
|
||||
const response = await client.post<BookingLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
unloadScheduleBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<BookingUnloadResult> => {
|
||||
const response = await client.post<BookingUnloadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getIntercityCandidates: async (
|
||||
scheduleId: string,
|
||||
): Promise<IntercityCandidatesResult> => {
|
||||
|
||||
@@ -37,6 +37,27 @@ export interface WagonListFilters {
|
||||
trainId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the wagon_movements ledger: every physical relocation between
|
||||
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
|
||||
* correction. Returned newest first by the API.
|
||||
*/
|
||||
export interface WagonMovementRecord {
|
||||
id: string;
|
||||
wagonId: string;
|
||||
fromYardId: string | null;
|
||||
toYardId: string;
|
||||
fromYard?: { id?: string; label?: string; code?: string } | null;
|
||||
toYard?: { id?: string; label?: string; code?: string } | null;
|
||||
trainScheduleId: string | null;
|
||||
bookingId: string | null;
|
||||
kind: Freight.WagonMovementKind;
|
||||
movedByUserId: string | null;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -49,6 +70,8 @@ export const wagonService = {
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getMovements: (id: string) =>
|
||||
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||
|
||||
@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
|
||||
@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
|
||||
| "APPROVED"
|
||||
| "PAID"
|
||||
| "IN_TRANSIT"
|
||||
| "ARRIVED"
|
||||
| "COMPLETED"
|
||||
| "REJECTED"
|
||||
| "CANCELLED";
|
||||
|
||||
@@ -796,3 +796,52 @@ export interface IntercityAcceptResult {
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
remaining: IntercityCapacity;
|
||||
}
|
||||
|
||||
// ── Yard load / unload worklist ──────────────────────────────────────────────
|
||||
// Per-booking journey along the train's corridor: every booking boards at its
|
||||
// origin yard and alights at its destination yard, confirmed by the yard
|
||||
// operator while the train's latest checkpoint is at that yard.
|
||||
|
||||
export interface YardWorkBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
tradeDirection: string;
|
||||
isGovernment: boolean;
|
||||
customer: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
loadedAt: string | null;
|
||||
arrivedAt: string | null;
|
||||
canLoad: boolean;
|
||||
canUnload: boolean;
|
||||
}
|
||||
|
||||
export interface YardWorkYard {
|
||||
yardId: string;
|
||||
yard: string;
|
||||
toLoad: YardWorkBookingRow[];
|
||||
toUnload: YardWorkBookingRow[];
|
||||
}
|
||||
|
||||
export interface YardWorkResult {
|
||||
scheduleId: string;
|
||||
scheduleStatus: string;
|
||||
trainAtYardId: string | null;
|
||||
yards: YardWorkYard[];
|
||||
}
|
||||
|
||||
export interface BookingLoadResult {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
export interface BookingUnloadResult {
|
||||
bookingId: string;
|
||||
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||
status: string;
|
||||
arrivedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user