mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -39,6 +39,7 @@ import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
@@ -265,6 +266,10 @@ const App = () => {
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={<BatchScheduleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
|
||||
@@ -136,9 +136,17 @@ export function AllocateBookingWizard({
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(
|
||||
scheduleMode === "new" && routeId ? routeId : undefined,
|
||||
);
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleMode === "new") {
|
||||
setLocomotiveId("");
|
||||
}
|
||||
}, [routeId, scheduleMode]);
|
||||
|
||||
const matchingSchedules = useMemo(
|
||||
() =>
|
||||
(schedulesQuery.data ?? []).filter(
|
||||
@@ -511,6 +519,7 @@ export function AllocateBookingWizard({
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code} · ${
|
||||
@@ -520,6 +529,10 @@ export function AllocateBookingWizard({
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
@@ -43,12 +43,15 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["train-scheduling"] as const,
|
||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
||||
locomotives: () => ["train-scheduling", "locomotives"] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -138,8 +138,12 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
|
||||
@@ -154,9 +154,15 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_PAYMENT: {
|
||||
title: "Awaiting Payment",
|
||||
description: "Selected in a batch — pay within 1 hour to secure the slot.",
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service";
|
||||
import { fleetService } from "@/services/fleet/fleet.service";
|
||||
|
||||
export function useFleetList(slug: FleetResourceSlug) {
|
||||
export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.FLEET.list(slug),
|
||||
queryFn: () => fleetService.list(slug),
|
||||
queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
|
||||
queryFn: () => fleetService.list(slug, filters),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,27 @@ export const useBatchBoard = () =>
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useBatchBoardDetail = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useRunAllocation = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => trainSchedulingService.runAllocation(scheduleId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
|
||||
@@ -43,10 +64,11 @@ export const useEligibleBookings = (
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useAvailableLocomotives = () =>
|
||||
export const useAvailableLocomotives = (routeId?: string) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId),
|
||||
enabled: routeId ? Boolean(routeId) : true,
|
||||
});
|
||||
|
||||
export const useBatchActions = (scheduleId?: string) => {
|
||||
@@ -54,10 +76,14 @@ export const useBatchActions = (scheduleId?: string) => {
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
if (scheduleId) {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { wagonService } from '@/services/wagon.service';
|
||||
|
||||
export type WagonListFilters = import('@/services/wagon.service').WagonListFilters;
|
||||
|
||||
export const wagonKeys = {
|
||||
all: ['wagons'] as const,
|
||||
list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const,
|
||||
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
||||
details: () => [...wagonKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...wagonKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
||||
export function useWagons(filters?: WagonListFilters) {
|
||||
return useQuery({
|
||||
queryKey: wagonKeys.list(filters),
|
||||
queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetWagons = useWagons;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -113,6 +114,35 @@ const newLine = (): ContainerLine => ({
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
|
||||
|
||||
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
function deriveTradeDirectionFromYards(
|
||||
origin?: RefNamed | null,
|
||||
destination?: RefNamed | null,
|
||||
): TradeDirection | null {
|
||||
const originCountry = origin?.country?.trim();
|
||||
const destinationCountry = destination?.country?.trim();
|
||||
if (!originCountry || !destinationCountry) return null;
|
||||
if (originCountry === "Djibouti") return "IMPORT";
|
||||
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
const tradeDirectionLabel: Record<TradeDirection, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Domestic",
|
||||
};
|
||||
|
||||
const parseBookingError = (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;
|
||||
};
|
||||
|
||||
/** Section card with a colored icon chip header. */
|
||||
function FormSection({
|
||||
icon: Icon,
|
||||
@@ -168,7 +198,6 @@ export default function NewBookingPage() {
|
||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [tradeDirection, setTradeDirection] = useState("IMPORT");
|
||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||
|
||||
// container freight
|
||||
@@ -223,7 +252,16 @@ export default function NewBookingPage() {
|
||||
? new Date(scheduledDate).toISOString()
|
||||
: "";
|
||||
|
||||
const yards = (refData?.yard ?? []).map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const yardRecords = refData?.yard ?? [];
|
||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setTrainScheduleId(null);
|
||||
}, [originYardId, destinationYardId]);
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
|
||||
@@ -265,13 +303,18 @@ export default function NewBookingPage() {
|
||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||
|
||||
const scheduleSatisfied =
|
||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(originYardId) &&
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(trainScheduleId) &&
|
||||
Boolean(tradeDirection) &&
|
||||
scheduleSatisfied &&
|
||||
Boolean(serviceTypeId) &&
|
||||
(Boolean(selectedSchedule) || Boolean(scheduledDate)) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
@@ -291,7 +334,7 @@ export default function NewBookingPage() {
|
||||
freightType,
|
||||
contractType: "NEW",
|
||||
equipmentReturn,
|
||||
tradeDirection,
|
||||
tradeDirection: tradeDirection!,
|
||||
paymentCurrency,
|
||||
isHazardous,
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
@@ -324,7 +367,7 @@ export default function NewBookingPage() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
|
||||
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||
},
|
||||
onError: () => toast.error("Failed to create booking"),
|
||||
onError: (error) => toast.error(parseBookingError(error, "Failed to create booking")),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -460,23 +503,30 @@ export default function NewBookingPage() {
|
||||
error={sameYard ? "Same as origin" : undefined}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
<Group grow>
|
||||
{hasBookableSchedules ? (
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
) : originYardId && destinationYardId ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No open train schedule on this route — set a preferred departure below. Staff can
|
||||
link a schedule later.
|
||||
</Text>
|
||||
) : null}
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Service type"
|
||||
placeholder="Select service"
|
||||
@@ -486,16 +536,24 @@ export default function NewBookingPage() {
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
data={[
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "DOMESTIC", label: "Domestic" },
|
||||
]}
|
||||
value={tradeDirection}
|
||||
onChange={(v) => setTradeDirection(v ?? "IMPORT")}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Trade direction
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={
|
||||
tradeDirection === "DOMESTIC"
|
||||
? "grape"
|
||||
: tradeDirection === "EXPORT"
|
||||
? "orange"
|
||||
: "blue"
|
||||
}
|
||||
>
|
||||
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
|
||||
import { useContainers } from "@/hooks/useContainers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWagons } from "@/hooks/useWagons";
|
||||
import type { FleetListFilters } from "@/services/fleet/fleet.service";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
getFleetResource,
|
||||
@@ -38,12 +39,30 @@ const FleetResourcePage = () => {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
if (slug !== "wagons" && slug !== "locomotives") return undefined;
|
||||
const filters: FleetListFilters = {};
|
||||
const status = listFilterValues.status;
|
||||
const readiness = listFilterValues.readiness;
|
||||
if (status && status !== "ALL") {
|
||||
filters.status = status as FleetListFilters["status"];
|
||||
}
|
||||
if (readiness && readiness !== "ALL") {
|
||||
filters.readiness = readiness as FleetListFilters["readiness"];
|
||||
}
|
||||
if (slug === "wagons" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
}
|
||||
return filters;
|
||||
}, [slug, listFilterValues, search]);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
|
||||
const { create, update, remove } = useFleetMutations(slug);
|
||||
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
@@ -56,12 +75,18 @@ const FleetResourcePage = () => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
setStatusFilter("ALL");
|
||||
setListFilterValues({});
|
||||
}, [slug, setPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (!hasStatusColumn) return [];
|
||||
if (!hasStatusColumn || usesServerListFilters) return [];
|
||||
const statuses = new Set(
|
||||
allRows
|
||||
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
|
||||
@@ -71,7 +96,19 @@ const FleetResourcePage = () => {
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...[...statuses].sort().map((status) => ({ value: status, label: status })),
|
||||
];
|
||||
}, [allRows, hasStatusColumn]);
|
||||
}, [allRows, hasStatusColumn, usesServerListFilters]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => ({
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
],
|
||||
}));
|
||||
}, [config?.listFilters, listFilterValues]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
|
||||
@@ -125,6 +162,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
@@ -138,7 +176,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -247,7 +285,27 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
listFilterSelects ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
key={filter.key}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
label={filter.label}
|
||||
value={filter.value}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={filter.data}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
|
||||
@@ -11,6 +11,10 @@ export type FleetResourceSlug =
|
||||
|
||||
export const FLEET_SELECT_NONE = "__none__";
|
||||
|
||||
import type { WagonListFilters } from "@/services/wagon.service";
|
||||
|
||||
export type FleetListFilters = WagonListFilters;
|
||||
|
||||
export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
@@ -30,6 +34,13 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
||||
noneOption?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
key: "status" | "readiness" | "wagonTypeId" | "trainId";
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
export interface FleetResourceConfig {
|
||||
slug: FleetResourceSlug;
|
||||
label: string;
|
||||
@@ -39,6 +50,8 @@ export interface FleetResourceConfig {
|
||||
entityLabel: string;
|
||||
searchPlaceholder: string;
|
||||
supportsSearch: boolean;
|
||||
/** Server-side list filters (e.g. wagon status / readiness). */
|
||||
listFilters?: FleetListFilterDef[];
|
||||
columns: FleetResourceColumn[];
|
||||
formFields: FleetFormFieldDef[];
|
||||
emptyValues: Record<string, unknown>;
|
||||
@@ -101,12 +114,27 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
removeSuccessMessage: "Locomotive decommissioned",
|
||||
cardTitleKey: "name",
|
||||
cardCodeKey: "code",
|
||||
cardSubtitleKey: "locomotiveType",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status"],
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: LOCOMOTIVE_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
|
||||
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
@@ -116,6 +144,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
@@ -127,6 +156,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
readiness: Freight.WagonReadiness.ImportReady,
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
@@ -187,6 +217,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
searchPlaceholder: "Search wagons…",
|
||||
supportsSearch: true,
|
||||
removeAction: "delete",
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -18,79 +17,30 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type {
|
||||
BatchBoardBooking,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardSchedule,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
AWAITING_PAYMENT: { label: "Awaiting payment", color: "orange", icon: Clock },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ booking }: { booking: BatchBoardBooking }) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm" py={6} px="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape" radius="sm">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.wagons}w · {fmtTons(booking.weightTons)}
|
||||
</Text>
|
||||
<StateBadge state={booking.state} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
const wagonPct =
|
||||
capacity.maxWagons > 0 ? (capacity.usedWagons / capacity.maxWagons) * 100 : 0;
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
@@ -103,9 +53,34 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
? "orange"
|
||||
: "gray";
|
||||
|
||||
const totalBookings =
|
||||
counts.allocated +
|
||||
counts.selectedForBatch +
|
||||
counts.ready +
|
||||
counts.waiting +
|
||||
counts.pendingContract +
|
||||
counts.expired;
|
||||
|
||||
return (
|
||||
<Paper radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}>
|
||||
<Box style={{ height: 3, background: "linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))" }} />
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
@@ -147,18 +122,37 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Capacity meters */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Wagons
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{capacity.usedWagons}/{capacity.maxWagons}
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={wagonPct} color={wagonPct >= 100 ? "orange" : "green"} radius="xl" size="sm" />
|
||||
</Box>
|
||||
{capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
@@ -172,20 +166,29 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={weightPct} color={weightPct >= 100 ? "red" : "teal"} radius="xl" size="sm" />
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Count chips */}
|
||||
<Group gap={6}>
|
||||
<Tooltip label="Allocated to the train">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{counts.allocated} allocated
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Notified — 1h to pay">
|
||||
<Tooltip label="Picked by batch — customer notified to pay">
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{counts.awaitingPayment} to pay
|
||||
{counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Contract signed — waiting for batch pick">
|
||||
<Badge variant="light" color="teal" radius="sm">
|
||||
{counts.ready} ready
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Paid, waiting for a slot">
|
||||
@@ -200,20 +203,11 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* Bookings */}
|
||||
{schedule.bookings.length ? (
|
||||
<ScrollArea.Autosize mah={220}>
|
||||
<Stack gap={2}>
|
||||
{schedule.bookings.map((b) => (
|
||||
<BookingRow key={b.id} booking={b} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center" py="sm">
|
||||
No bookings targeting this schedule yet.
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{totalBookings
|
||||
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
|
||||
: "No bookings yet · click to open"}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -221,11 +215,12 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
radius="md"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.scheduleId}`)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
View batch windows
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -238,9 +233,7 @@ export default function BatchBoardPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Operations" }, { label: "Batch board" }]}
|
||||
/>
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
@@ -265,8 +258,8 @@ export default function BatchBoardPage() {
|
||||
Batch board
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Every active schedule with its bookings grouped by state — allocated, awaiting
|
||||
payment, paid-waiting and expired. Filling is automatic; this is the live view.
|
||||
Active schedules — click a card to see EAT 3-hour batch windows, bookings, and
|
||||
wagon allocation status.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
Layers,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Weight,
|
||||
Ruler,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
useBatchBoardDetail,
|
||||
useRunAllocation,
|
||||
useScheduleDetail,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
|
||||
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
const ALLOC_META: Record<
|
||||
BookingAllocationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ASSIGNED: { label: "Wagons assigned", color: "green" },
|
||||
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
|
||||
DEFERRED: { label: "Deferred", color: "orange" },
|
||||
FAILED: { label: "Allocation failed", color: "red" },
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationBadge({
|
||||
status,
|
||||
issue,
|
||||
}: {
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}) {
|
||||
const meta = ALLOC_META[status];
|
||||
const badge = (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
if (!issue) return badge;
|
||||
return (
|
||||
<Tooltip label={issue} multiline maw={320} withArrow>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{badge}
|
||||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
if (!bookings.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No bookings in this batch window.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Contract signed</Table.Th>
|
||||
<Table.Th>Selected for batch</Table.Th>
|
||||
<Table.Th>Capacity</Table.Th>
|
||||
<Table.Th>Batch state</Table.Th>
|
||||
<Table.Th>Wagon allocation</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.company}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.selectedForBatchAt ? (
|
||||
<>
|
||||
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange">
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{b.wagons}w · {fmtTons(b.weightTons)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StateBadge state={b.state} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion.Item value={window.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
{window.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{hasIssues ? (
|
||||
<Badge variant="light" color="red" size="sm">
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{total} booking{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={window.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchScheduleDetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
|
||||
const runAllocation = useRunAllocation(scheduleId ?? "");
|
||||
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const scheduleDetailQuery = useScheduleDetail(
|
||||
hasAssignedWagons ? scheduleId : undefined,
|
||||
"CONTAINER",
|
||||
);
|
||||
|
||||
const defaultOpen = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
|
||||
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
|
||||
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
|
||||
}, [data]);
|
||||
|
||||
const handleRunAllocation = () => {
|
||||
runAllocation
|
||||
.mutateAsync()
|
||||
.then((result) => {
|
||||
const failed = result.issues.filter((i) => i.status === "FAILED").length;
|
||||
const deferred = result.deferred.length;
|
||||
toast({
|
||||
title: "Allocation run complete",
|
||||
description:
|
||||
failed || deferred
|
||||
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
|
||||
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
void refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper radius="xl" p="xl" mt="md" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<Train size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.origin ?? "—"} → {data.destination ?? "—"} ·{" "}
|
||||
{data.scheduleDate
|
||||
? new Date(data.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Badge variant="light" color="green">
|
||||
{data.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{data.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data.locomotive ? (
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{data.capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{data.capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtMeters(data.capacity.allocatedLengthMeters)}/
|
||||
{fmtMeters(data.capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{data.capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Weight size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap={6} mt="md">
|
||||
<Badge variant="light" color="green">
|
||||
{data.counts.allocated} allocated
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange">
|
||||
{data.counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
<Badge variant="light" color="teal">
|
||||
{data.counts.ready} ready
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue">
|
||||
{data.counts.waiting} waiting
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray">
|
||||
{data.counts.pendingContract} pending contract
|
||||
</Badge>
|
||||
{data.counts.expired ? (
|
||||
<Badge variant="light" color="red">
|
||||
{data.counts.expired} expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Batch windows (EAT)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
|
||||
window to see bookings and wagon allocation issues.
|
||||
</Text>
|
||||
|
||||
<Accordion multiple defaultValue={defaultOpen} variant="separated">
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Train composition
|
||||
</Title>
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
@@ -81,7 +81,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
|
||||
const { create, cancel } = useScheduleMutations();
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
@@ -89,6 +89,23 @@ export default function TrainScheduleV2ListPage() {
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
const locomotiveReadinessHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const origin = selectedRoute.originYard?.country?.trim();
|
||||
const dest = selectedRoute.destinationYard?.country?.trim();
|
||||
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
|
||||
if (dest === "Djibouti" && origin !== "Djibouti") {
|
||||
return "Export corridor — export-ready locomotives only";
|
||||
}
|
||||
return "Domestic corridor — any readiness";
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocomotiveId("");
|
||||
}, [routeId]);
|
||||
|
||||
const allSchedules = schedulesQuery.data ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -507,6 +524,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
{routeId ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{locomotiveReadinessHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
@@ -518,7 +540,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder="Select locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""} · ${
|
||||
@@ -528,6 +550,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { cargoService, type Cargo } from "@/services/cargoService";
|
||||
import { containerService, type Container } from "@/services/containerService";
|
||||
import { locomotivesService, type Locomotive } from "@/services/locomotives.service";
|
||||
import {
|
||||
locomotivesService,
|
||||
type Locomotive,
|
||||
type LocomotiveListFilters,
|
||||
} from "@/services/locomotives.service";
|
||||
import { trainService, type Train } from "@/services/trains.service";
|
||||
import { wagonService, type Wagon } from "@/services/wagon.service";
|
||||
import { wagonService, type Wagon, type WagonListFilters } from "@/services/wagon.service";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetRecord = Locomotive | Train | Wagon | Container | Cargo;
|
||||
|
||||
const listHandlers: Record<FleetResourceSlug, () => Promise<FleetRecord[]>> = {
|
||||
locomotives: () => locomotivesService.getAll().then((r) => r.data),
|
||||
export type FleetListFilters = WagonListFilters & LocomotiveListFilters;
|
||||
|
||||
const listHandlers: Record<
|
||||
FleetResourceSlug,
|
||||
(filters?: FleetListFilters) => Promise<FleetRecord[]>
|
||||
> = {
|
||||
locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
|
||||
trains: () => trainService.getAll().then((r) => r.data),
|
||||
wagons: () => wagonService.getAll().then((r) => r.data),
|
||||
wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data),
|
||||
containers: () => containerService.getAll().then((r) => r.data),
|
||||
cargoes: () => cargoService.getAll().then((r) => r.data),
|
||||
};
|
||||
@@ -43,7 +52,7 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
||||
};
|
||||
|
||||
export const fleetService = {
|
||||
list: (slug: FleetResourceSlug) => listHandlers[slug](),
|
||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
|
||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||
updateHandlers[slug](id, data),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -9,12 +11,18 @@ export type LocomotiveStatus =
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
export interface LocomotiveListFilters {
|
||||
status?: LocomotiveStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
}
|
||||
|
||||
export interface Locomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
readiness: Freight.WagonReadiness;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
@@ -30,7 +38,15 @@ export type SaveLocomotivePayload = Omit<
|
||||
>;
|
||||
|
||||
export const locomotivesService = {
|
||||
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||
getAll: (filters: LocomotiveListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Locomotive[]>(
|
||||
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unwrap } from '@/utils/endpoint';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from '@/types/trainScheduling';
|
||||
|
||||
@@ -86,6 +88,13 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.get<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
@@ -97,14 +106,22 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
|
||||
const response = await client.post<WagonAllocationAttemptResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setBookingWindow: async (
|
||||
scheduleId: string,
|
||||
status: "OPEN" | "CLOSED",
|
||||
@@ -232,7 +249,14 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
|
||||
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
|
||||
if (routeId) {
|
||||
const response = await client.get<LocomotiveRecord[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
|
||||
{ params: { routeId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
}
|
||||
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
|
||||
params: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
@@ -15,8 +15,25 @@ export interface Wagon {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WagonListFilters {
|
||||
search?: string;
|
||||
status?: Freight.WagonStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
wagonTypeId?: string;
|
||||
trainId?: string;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search?.trim()) params.set('search', filters.search.trim());
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
|
||||
if (filters.trainId) params.set('trainId', filters.trainId);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
|
||||
@@ -180,7 +180,8 @@ export interface BookableSchedule {
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
| "AWAITING_PAYMENT"
|
||||
| "SELECTED_FOR_BATCH"
|
||||
| "READY"
|
||||
| "WAITING"
|
||||
| "PENDING_CONTRACT"
|
||||
| "EXPIRED";
|
||||
@@ -192,6 +193,7 @@ export interface BatchBoardBooking {
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
@@ -212,15 +214,16 @@ export interface BatchBoardSchedule {
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
maxWagons: number;
|
||||
usedWagons: number;
|
||||
remainingWagons: number;
|
||||
allocatedWagons: number;
|
||||
allocatedLengthMeters: number;
|
||||
maxLengthMeters: number | null;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
awaitingPayment: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
@@ -228,6 +231,63 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
| "DEFERRED"
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
export interface WagonAllocationAttemptResult {
|
||||
assignedBookingIds: string[];
|
||||
deferred: Array<{ id: string; reference: string; reason: string }>;
|
||||
issues: Array<{
|
||||
bookingId: string;
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}>;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface TrainScheduleWagonAllocation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
|
||||
Reference in New Issue
Block a user