This commit is contained in:
Marshal
2026-07-07 12:28:47 +00:00
parent 1c18fdbd52
commit f300600bfa
63 changed files with 2587 additions and 428 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -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}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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" },
],
},

View File

@@ -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}

View File

@@ -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

View File

@@ -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> => {

View File

@@ -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 }),

View File

@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"REJECTED",
"CANCELLED",

View File

@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
| "APPROVED"
| "PAID"
| "IN_TRANSIT"
| "ARRIVED"
| "COMPLETED"
| "REJECTED"
| "CANCELLED";

View File

@@ -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;
}