Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

View File

@@ -68,6 +68,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
import TrainCrewPage from "./pages/train-crew/TrainCrewPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
@@ -87,6 +88,7 @@ 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 ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
@@ -900,6 +902,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<ScheduleCrewPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
@@ -1017,6 +1027,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="train-crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainCrew.view}>
<TrainCrewPage />
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={

View File

@@ -1,8 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { OperationRescheduleModal } from "./OperationRescheduleModal";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
@@ -10,6 +12,7 @@ import {
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
isRescheduleAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -44,6 +47,8 @@ export function BookingActionsMenu({
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
// Day / train reschedule has its own modal (date + export train picker).
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
@@ -67,6 +72,8 @@ export function BookingActionsMenu({
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else if (isRescheduleAction(action.id)) {
setRescheduleOpen(true);
} else {
flow.openAction(action);
}
@@ -109,6 +116,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</>
);
}
@@ -183,6 +198,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</Group>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Select,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { CalendarClock, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
export interface OperationRescheduleModalProps {
bookingId: string;
opened: boolean;
onClose: () => void;
}
/**
* Operations moves a pending operation request to another shipment day and,
* for export rail, another train — instead of returning it to the customer.
* The server re-runs the customer's own gates (open departure that day, wagon
* that can carry the cargo, export train with room) and refuses with the
* reason if the new day does not work.
*/
export function OperationRescheduleModal({
bookingId,
opened,
onClose,
}: OperationRescheduleModalProps) {
const detailQuery = useBookingDetail(opened ? bookingId : undefined);
const booking = detailQuery.data;
const mutations = useBookingMutations(bookingId);
const isExportRail = booking ? isExportRailBooking(booking) : false;
const [day, setDay] = useState<Date | null>(null);
const [trainId, setTrainId] = useState<string | null>(null);
const [note, setNote] = useState("");
// Seed from the booking each time the modal opens: the current day and, for
// export, the train the customer picked (the detail's requested/allocated train).
useEffect(() => {
if (!opened || !booking) return;
setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null);
setTrainId(booking.trainScheduleSummary?.id ?? null);
setNote("");
}, [opened, booking]);
const dayKey = day ? eatDay(day) : null;
const currentDayKey = booking?.scheduledDate
? eatDay(booking.scheduledDate)
: null;
// Days with an open departure on the booking's route — a planning hint; the
// server still validates the pick.
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
opened &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const availableDays = useMemo(
() => new Set((daysQuery.data ?? []).map((d) => eatDay(d))),
[daysQuery.data],
);
const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false;
// Export rail: the day's export trains with free space, so staff pick one.
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: day ? day.toISOString() : "" },
}),
enabled: opened && isExportRail && Boolean(day),
});
const trainOptions = useMemo(
() => (trainsQuery.data ?? []).map(exportTrainOption),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.
useEffect(() => {
if (!isExportRail || !trainsQuery.data) return;
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
setTrainId(null);
}
}, [isExportRail, trainsQuery.data, trainId]);
const unchanged =
dayKey != null &&
dayKey === currentDayKey &&
(!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null));
const canSave =
Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId));
const handleSave = () => {
if (!day || !canSave) return;
mutations.rescheduleOperation.mutate(
{
scheduledDate: day.toISOString(),
...(isExportRail && trainId ? { trainScheduleId: trainId } : {}),
...(note.trim() ? { note: note.trim() } : {}),
},
{ onSuccess: () => onClose() },
);
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Change train / shipment day
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{booking?.reference ?? "Booking"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !booking ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Sets the shipment day
{isExportRail ? " and the export train " : " "}
for the customer, so nothing has to go back to them. The request
stays under review for the normal accept, and the customer is told
the new day.
{!isExportRail
? " Import and domestic trains are assigned by the batch engine on the chosen day."
: ""}
</Alert>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="gray">
Currently {currentDayKey ?? "no day"}
</Badge>
{booking.trainScheduleSummary ? (
<Badge variant="light" color="gray">
{booking.trainScheduleSummary.trainNumber ??
booking.trainScheduleSummary.reference ??
"train"}
{booking.trainScheduleSummary.isRequested ? " (requested)" : ""}
</Badge>
) : null}
</Group>
<DateInput
label="New shipment day"
description={
daysQuery.data && daysQuery.data.length
? "Days with an open departure on this route are selectable."
: "Pick the train departure day."
}
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
excludeDate={
daysQuery.data && daysQuery.data.length
? (d) => !availableDays.has(eatDay(d))
: undefined
}
popoverProps={{ withinPortal: true }}
/>
{day &&
daysQuery.data &&
daysQuery.data.length &&
!dayHasDeparture ? (
<Text size="xs" c="red">
No open departure on this route for {dayKey}.
</Text>
) : null}
{isExportRail ? (
<Select
label="Export train"
placeholder={
!day
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!day || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
/>
) : null}
<Textarea
label="Note to customer (optional)"
placeholder="Why the day is changing…"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<CalendarClock size={16} />}
loading={mutations.rescheduleOperation.isPending}
disabled={!canSave}
onClick={handleSave}
>
Save new day
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
export default OperationRescheduleModal;

View File

@@ -1,11 +1,27 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Alert,
Button,
Group,
Paper,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import {
eatDay,
exportTrainOption,
formatEatDay,
isExportRailBooking,
} from "@/features/bookings/shipmentDay";
export interface BookingChangesRequestedAlertProps {
bookingId: string;
@@ -27,8 +43,11 @@ export interface BookingChangesRequestedAlertProps {
*
* The customer cannot act on this — GL created the booking on their behalf — so
* the note and the way out both live here, on the page GL works from. Resubmit
* re-requests operation on the chosen shipment day; the server re-checks the day
* has a departure that can carry the cargo and refuses with the reason if not.
* re-requests operation on the chosen shipment day: only days with an open
* departure on the booking's route are selectable, and an export rail booking
* also picks the train it rides (the API refuses an export resubmit without
* one). The server re-checks the day and train and refuses with the reason if
* they no longer work.
*/
export function BookingChangesRequestedAlert({
bookingId,
@@ -39,16 +58,82 @@ export function BookingChangesRequestedAlert({
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
scheduledDate ? new Date(scheduledDate) : null,
// The chosen departure day, as an EAT day key (YYYY-MM-DD). Only days that
// actually have an open departure on the booking's route are offered.
const [dayKey, setDayKey] = useState<string | null>(
scheduledDate ? eatDay(scheduledDate) : null,
);
const [trainId, setTrainId] = useState<string | null>(null);
const [sending, setSending] = useState(false);
// The booking's route and direction decide which days are offered and
// whether a train has to be picked — fetched only when this user can resubmit.
const { data: booking } = useBookingDetail(
canResubmit ? bookingId : undefined,
);
const isExportRail = booking ? isExportRailBooking(booking) : false;
// Seed the train from the customer's / previous pick once the booking loads.
useEffect(() => {
if (booking?.trainScheduleSummary?.id) {
setTrainId((current) => current ?? booking.trainScheduleSummary!.id);
}
}, [booking]);
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
canResubmit &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const dayOptions = useMemo(
() =>
Array.from(new Set((daysQuery.data ?? []).map((d) => eatDay(d))))
.sort()
.map((key) => ({ value: key, label: formatEatDay(key) })),
[daysQuery.data],
);
// A previously held day that no longer has a departure is not offered — the
// select shows nothing until GL picks a real one.
const dayHasDeparture =
dayKey != null && dayOptions.some((o) => o.value === dayKey);
// Any instant inside the chosen EAT day; the API keys on the day.
const dayIso = dayKey ? `${dayKey}T12:00:00.000Z` : "";
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: dayIso },
}),
enabled: canResubmit && isExportRail && dayHasDeparture,
});
const trainOptions = useMemo(
() => (trainsQuery.data ?? []).map(exportTrainOption),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.
useEffect(() => {
if (!isExportRail || !trainsQuery.data) return;
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
setTrainId(null);
}
}, [isExportRail, trainsQuery.data, trainId]);
const canSend = dayHasDeparture && (!isExportRail || Boolean(trainId));
const resubmit = async () => {
if (!day) return;
if (!canSend) return;
setSending(true);
try {
await bookingsService.proceedToOperation(bookingId, day.toISOString());
await bookingsService.proceedToOperation(
bookingId,
dayIso,
isExportRail && trainId ? trainId : undefined,
);
toast.success("Sent back to Operations for review");
onResubmitted?.();
} catch {
@@ -91,8 +176,9 @@ export function BookingChangesRequestedAlert({
)}
<Text size="sm">
This booking was created by GL Ethiopia, so the customer cannot fix it.
Make the correction Operations asked for, then send it back for review.{" "}
This booking was created by GL Ethiopia, so the customer cannot fix
it. Make the correction Operations asked for, then send it back for
review.{" "}
<Text
component={Link}
to={`/dashboard/bookings/${bookingId}/clearance`}
@@ -106,21 +192,54 @@ export function BookingChangesRequestedAlert({
{canResubmit ? (
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="Shipment day"
description="Keep the day or pick another with an open departure"
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
<Select
label="Departure day"
description="Existing departures on this route"
placeholder={
daysQuery.isLoading
? "Loading departures…"
: dayOptions.length
? "Select a departure day"
: "No open departure on this route"
}
data={dayOptions}
value={dayHasDeparture ? dayKey : null}
onChange={setDayKey}
disabled={daysQuery.isLoading || !dayOptions.length}
nothingFoundMessage="No open departure on this route"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={230}
/>
{isExportRail ? (
<Select
label="Export train"
description="The train this shipment rides"
placeholder={
!dayHasDeparture
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!dayHasDeparture || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={340}
/>
) : null}
<Button
color="red"
radius="md"
size="sm"
loading={sending}
disabled={!day}
disabled={!canSend}
leftSection={<Send size={15} />}
onClick={() => void resubmit()}
>

View File

@@ -1,9 +1,19 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Button,
Group,
Modal,
NumberInput,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
Ban,
CalendarClock,
CalendarPlus,
Check,
Eye,
// FilePen, // ponytail: back with the "Edit contract articles" button
@@ -84,6 +94,9 @@ export function ContractActionsToolbar({
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
// Revive an EXPIRED contract by adding validity days — only after the
// customer asked for it from the portal (the API enforces the same).
const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -98,6 +111,9 @@ export function ContractActionsToolbar({
const [resumeNote, setResumeNote] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [extendOpen, setExtendOpen] = useState(false);
const [extendDays, setExtendDays] = useState<number>(30);
const [extendNote, setExtendNote] = useState("");
// Shared by the suspended branch and the normal toolbar — both can cancel.
const cancelModal = (
@@ -183,7 +199,132 @@ export function ContractActionsToolbar({
[validitySetting],
);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
// Lapsed: nothing to do until the customer asks for more time from the
// portal. Once they have, staff add days and the contract returns to the
// status it held before it expired.
if (status === "EXPIRED") {
const requestedAt = contract.extensionRequestedAt
? new Date(contract.extensionRequestedAt)
: null;
const currentEnd = contract.contractValidUntil
? new Date(contract.contractValidUntil)
: null;
// Mirrors ContractTransitionService.extend: days count from today once the
// contract has lapsed, from the current end date otherwise.
const base =
currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date();
const newEnd = new Date(base);
newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0)));
const restoredStatus =
contract.statusBeforeExpiry ??
(contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED");
const daysValid = Number.isInteger(extendDays) && extendDays >= 1;
return (
<SectionCard icon={CalendarClock} title="Contract expired">
<Stack gap="sm">
<Text size="sm" c="dimmed">
This contract's validity ended
{currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New
bookings are blocked until it is extended.
</Text>
{requestedAt ? (
<>
<Text size="sm">
<b>Extension requested</b> by the customer on{" "}
{requestedAt.toLocaleDateString()}.
</Text>
{contract.latestExtensionRequestNote && (
<Text size="sm">
<b>Reason:</b> {contract.latestExtensionRequestNote}
</Text>
)}
{mayExtend ? (
<Button
fullWidth
color="edr-green"
leftSection={<CalendarPlus size={16} />}
onClick={() => setExtendOpen(true)}
>
Extend contract
</Button>
) : (
<Text size="sm" c="dimmed">
You do not have permission to extend a contract.
</Text>
)}
</>
) : (
<Text size="sm" c="dimmed">
The customer has not requested an extension. A contract can only
be extended once they ask for it from the portal.
</Text>
)}
</Stack>
<Modal
opened={extendOpen}
onClose={() => setExtendOpen(false)}
title="Extend this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> gets the days below added to
its validity, returns to <b>{restoredStatus}</b>, and the customer
is notified. Bookings under it are possible again immediately.
</Text>
<NumberInput
label="Days to add"
min={1}
max={3650}
step={1}
allowDecimal={false}
value={extendDays}
onChange={(v) => setExtendDays(typeof v === "number" ? v : Number(v) || 0)}
/>
<Text size="sm" c="dimmed">
New validity end:{" "}
<b>{daysValid ? newEnd.toLocaleDateString() : "—"}</b>
</Text>
<Textarea
label="Note (optional)"
placeholder="Shown to the customer with the extension…"
autosize
minRows={2}
value={extendNote}
onChange={(e) => setExtendNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setExtendOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!daysValid}
loading={mutations.extend.isPending}
onClick={() =>
mutations.extend.mutate(
{ days: extendDays, note: extendNote.trim() || undefined },
{
onSuccess: () => {
setExtendOpen(false);
setExtendNote("");
},
},
)
}
>
Extend contract
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}
if (["REJECTED", "CANCELLED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}

View File

@@ -582,13 +582,39 @@ export default function GlCreateBookingForm() {
}
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
// item count isn't on the booking payload yet, so bulk rebooks fall through to
// the normal contract seed and GL re-enters the quantity.)
// Rebook seed: copy the source booking's real cargo once — container lines
// (with their per-unit details) or the bulk weight / item count / wagons.
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
if (!lines.length) {
// Bulk booking: seed the quantity fields from what was actually booked.
// A break-bulk (per-item) booking stores the real tons in
// bulkTotalWeightTons and the item count in cargoTotalWeightVgm.
const perItem = copyFromBooking.bulkTotalWeightTons != null;
const tons = perItem
? copyFromBooking.bulkTotalWeightTons
: copyFromBooking.cargoTotalWeightVgm;
const items = perItem
? copyFromBooking.cargoTotalWeightVgm
: copyFromBooking.bulkItemCount;
if (!(Number(tons) > 0) && !(Number(items) > 0)) return;
setPrefilled(true);
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setBulk((b) => ({
...b,
cargoWeightTons: Number(tons) > 0 ? String(tons) : "",
itemCount: Number(items) > 0 ? String(items) : "",
requestedWagons:
copyFromBooking.bulkRequestedWagons != null &&
copyFromBooking.bulkRequestedWagons > 0
? String(copyFromBooking.bulkRequestedWagons)
: b.requestedWagons,
}));
return;
}
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
@@ -636,13 +662,23 @@ export default function GlCreateBookingForm() {
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
//
// Functional update on purpose: when the page is reached by an in-app click
// the contract AND the rebook source are both already cached, so this effect
// and the copyFrom seed above fire in the SAME commit. Reading
// `containerLines` from the closure here saw the pre-seed empty array and
// overwrote the copied lines with blank 0 × 20ft / 0 × 40ft rows (a hard
// refresh loaded them in sequence and looked fine). The updater sees the
// copied lines already queued and leaves them alone.
useEffect(() => {
if (!contract || prefilled || seededRef.current) return;
seededRef.current = true;
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
setContainerLines(containerSizes.map(emptyLine));
if (isContainer && containerSizes.length > 0) {
setContainerLines((prev) =>
prev.length === 0 ? containerSizes.map(emptyLine) : prev,
);
}
}, [contract, prefilled, isContainer, containerSizes, containerLines.length]);
}, [contract, prefilled, isContainer, containerSizes]);
const quantities: GlShipmentQuantities = useMemo(
() => ({

View File

@@ -29,6 +29,13 @@ const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules
);
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
{
prefix: "/dashboard/train-crew",
meta: {
title: "Train Crew",
subtitle: "Roster of on-board personnel assignable to a train",
},
},
{
prefix: "/dashboard/overview",
meta: {

View File

@@ -523,6 +523,18 @@ export const buildSidebarSections = (
},
],
},
{
title: "Rolling stock",
mutedTitle: true,
items: [
{
label: "Train Crew",
href: "/dashboard/train-crew",
icon: <Users />,
permission: FREIGHT_PERMS.trainCrew.view,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Divider,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { Info, Unlock } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import DurationField from "@/components/trainScheduling/DurationField";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
const EAT = "Africa/Addis_Ababa";
/** "3 days" / "2 hr" / "45 min" for a minute count. */
function describeMinutes(minutes: number): string {
if (minutes <= 0) return "at departure";
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return `${d} day${d === 1 ? "" : "s"}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hr`;
}
return `${minutes} min`;
}
function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function parseError(error: unknown, fallback: string): 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;
}
export interface ReduceCloseOffsetModalProps {
scheduleId: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Reopen a schedule whose booking shut ONLY because of its close offset, by
* shortening that offset (3 days → 1 day, 2 hours, …). The API decides
* eligibility (`closeOffsetReopen`); every other kind of closed window is
* explained and left alone.
*/
export default function ReduceCloseOffsetModal({
scheduleId,
opened,
onClose,
onSaved,
}: ReduceCloseOffsetModalProps) {
const { toast } = useToast();
const detailQuery = useQuery({
...api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
}),
enabled: opened && Boolean(scheduleId),
});
const schedule = detailQuery.data;
const reopen = schedule?.closeOffsetReopen ?? null;
const currentOffset = reopen?.offsetMinutes ?? null;
const save = useMutation(
api.trainScheduling.reduceScheduleCloseOffset.mutationOptions(),
);
// New offset, in minutes (what the API stores). Seeded to the current offset
// so the field reads as "shorten this", never as an empty box.
const [offsetMinutes, setOffsetMinutes] = useState<number | "">("");
useEffect(() => {
if (!opened) return;
setOffsetMinutes(currentOffset ?? "");
}, [opened, currentOffset]);
const departure = schedule?.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate)
: null;
const newCutoff = useMemo(() => {
if (!departure || offsetMinutes === "") return null;
const n = Number(offsetMinutes);
if (!Number.isFinite(n) || n < 0) return null;
return new Date(departure.getTime() - n * 60_000);
}, [departure, offsetMinutes]);
const value = offsetMinutes === "" ? NaN : Number(offsetMinutes);
const isShorter =
Number.isFinite(value) && currentOffset != null && value < currentOffset;
const cutoffInPast = newCutoff != null && newCutoff.getTime() <= Date.now();
const canSave =
reopen?.eligible === true && isShorter && value >= 0 && !cutoffInPast;
const handleSave = async () => {
if (!scheduleId || !canSave) return;
try {
await save.mutateAsync({
id: scheduleId,
payload: { closeOffsetMinutes: Math.round(value) },
});
toast({
title: "Booking window reopened",
description: `Booking now closes ${describeMinutes(Math.round(value))} before departure.`,
});
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Could not reopen booking",
description: parseError(err, "The close offset was not changed."),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Unlock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Reopen booking shorten close offset
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !schedule ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !reopen?.eligible ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="The close offset is not what closed this train"
>
{reopen?.reason ??
"This schedule cannot be reopened by shortening its close offset."}
</Alert>
) : (
<Stack gap="lg">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Booking on this train closed early because of its close offset the
train itself has not left. Shorten the offset and the desk reopens
at its next opening (right away if it is open now) until the new
cutoff.
{schedule.direction !== "EXPORT"
? " Every train on this route departing the same day that is closed for the same reason reopens with it."
: ""}
</Alert>
<Box>
<Text size="sm" fw={600} mb={6}>
Current close
</Text>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="red">
Closes {describeMinutes(currentOffset ?? 0)} before departure
</Badge>
<Text size="xs" c="dimmed">
closed {formatEat(reopen.cutoffAt)} EAT · departs{" "}
{formatEat(departure)} EAT
</Text>
</Group>
</Box>
<Divider />
<DurationField
label="New close offset (before departure)"
description="Must be shorter than the current offset. 0 = booking stays open until the train departs."
value={offsetMinutes}
nativeUnit="minutes"
min={0}
onChange={setOffsetMinutes}
/>
{newCutoff ? (
<Text size="sm">
Booking would now close{" "}
<Text span fw={600}>
{formatEat(newCutoff)} EAT
</Text>
{cutoffInPast ? (
<Text span c="red">
{" "}
that is already in the past; shorten it further.
</Text>
) : !isShorter ? (
<Text span c="red">
{" "}
not shorter than the current offset.
</Text>
) : null}
</Text>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<Unlock size={16} />}
loading={save.isPending}
disabled={!canSave}
onClick={() => void handleSave()}
>
Reopen booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -194,6 +194,13 @@ export const QUERY_KEYS = {
byId: (id: string) => ["vehicles", "detail", id] as const,
},
TRAIN_CREW: {
ROOT: ["train-crew"] as const,
list: (filter?: Record<string, unknown>) =>
["train-crew", "list", filter ?? {}] as const,
byId: (id: string) => ["train-crew", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) =>

View File

@@ -264,6 +264,8 @@ export const URL_CONSTANTS = {
`/bookings/${id}/clearance/export-release`,
// Re-request operation after Operations sent the booking back for changes.
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
// Operations changes a pending request's shipment day / train themselves.
OPERATION_RESCHEDULE: (id: string) => `/bookings/${id}/operation/reschedule`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -286,6 +288,7 @@ export const URL_CONSTANTS = {
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
RESUME: (id: string) => `/contracts/${id}/resume`,
EXTEND: (id: string) => `/contracts/${id}/extend`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
@@ -435,6 +438,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/booking-window`,
WINDOW_RULE: (id: string) =>
`/train-scheduling/schedules/${id}/window-rule`,
CLOSE_OFFSET: (id: string) =>
`/train-scheduling/schedules/${id}/close-offset`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
@@ -871,4 +876,9 @@ export const URL_CONSTANTS = {
BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,
},
TRAIN_CREW: {
BASE: "/train-crew",
BY_ID: (id: string) => `/train-crew/${id}`,
},
};

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
Ban,
CalendarClock,
Check,
MessageSquareWarning,
Play,
@@ -28,6 +29,7 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationReschedule"
| "cancel";
export type BookingActionInputKind =
@@ -128,6 +130,22 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
/**
* Operations changes the shipment day / train themselves, instead of returning
* the request to the customer. Opens its own modal (day + export train picker),
* not the generic confirm dialog — see isRescheduleAction.
*/
const OPERATION_RESCHEDULE_ACTION: BookingActionDef = {
id: "operationReschedule",
label: "Change train / shipment day",
shortLabel: "Reschedule",
description: "Move the request to another shipment day or train yourself",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: CalendarClock,
};
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
@@ -156,6 +174,7 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
OPERATION_RESCHEDULE_ACTION,
];
const CANCEL_ACTION: BookingActionDef = {
@@ -202,6 +221,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationReschedule: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -253,6 +273,11 @@ export function getBookingActions(
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "OPERATION_CHANGES_REQUESTED":
// Waiting on the customer — but Operations may also resolve their own
// change request by setting the day / train directly.
actions = [OPERATION_RESCHEDULE_ACTION];
break;
case "PAID":
// Allocate is handled by the Operations "Ready to allocate" queue, not the
// per-booking action menu. Start transit was removed entirely. No per-row
@@ -300,6 +325,11 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the day / train reschedule modal instead of the generic confirm dialog. */
export function isRescheduleAction(id: BookingActionId): boolean {
return id === "operationReschedule";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";

View File

@@ -0,0 +1,88 @@
/**
* Shared helpers for the staff shipment-day / export-train pickers (the
* operation reschedule modal and the GL "returned for changes" resubmit).
*/
export const EAT_TIMEZONE = "Africa/Addis_Ababa";
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
export function eatDay(value: string | Date): string {
const date = typeof value === "string" ? new Date(value) : value;
return new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
/** "Mon, 07 Sep, 09:00" in EAT; "—" for a missing or invalid value. */
export function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/** "Wed, 09 Sep 2026" for a YYYY-MM-DD EAT day key. */
export function formatEatDay(dayKey: string): string {
const date = new Date(`${dayKey}T12:00:00.000Z`);
if (Number.isNaN(date.getTime())) return dayKey;
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
export function isRoadServiceCode(code: string | null | undefined): boolean {
const c = (code ?? "").toUpperCase();
return (
c === "ROAD" ||
c === "TRUCK" ||
c.startsWith("ROAD_") ||
c.startsWith("TRUCK_")
);
}
/** Export rail bookings are the only ones that carry a train pick. */
export function isExportRailBooking(booking: {
tradeDirection?: string | null;
serviceType?: { code?: string | null } | null;
}): boolean {
return (
booking.tradeDirection === "EXPORT" &&
!isRoadServiceCode(booking.serviceType?.code)
);
}
/** Select option for one export train; closed or too-small trains are disabled. */
export function exportTrainOption(t: {
scheduleId: string;
trainNumber: string | null;
trainName: string | null;
departure: string;
isOpen: boolean;
fits: boolean;
freeWagons: number;
neededWagons: number;
}): { value: string; label: string; disabled: boolean } {
return {
value: t.scheduleId,
label:
`${t.trainNumber ?? t.trainName ?? "Train"} · departs ${formatEat(t.departure)} · ` +
`${t.freeWagons} free / needs ${t.neededWagons}` +
(!t.isOpen ? " · closed" : !t.fits ? " · no room" : ""),
disabled: !t.isOpen || !t.fits,
};
}

View File

@@ -64,6 +64,17 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const rescheduleOperation = useMutation({
mutationFn: (payload: {
scheduledDate: string;
trainScheduleId?: string;
note?: string;
}) => api.bookings.rescheduleOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Shipment day updated"),
onError: (error) =>
toast.error(parseApiError(error, "Failed to change the shipment day")),
});
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES";
@@ -162,6 +173,7 @@ export function useBookingMutations(bookingId: string) {
startTransit.isPending ||
complete.isPending ||
reviewOperation.isPending ||
rescheduleOperation.isPending ||
cancel.isPending;
return {
@@ -170,6 +182,7 @@ export function useBookingMutations(bookingId: string) {
requestChanges,
staffReject,
reviewOperation,
rescheduleOperation,
generateContract,
signContract,
payBooking,

View File

@@ -172,6 +172,14 @@ export function useContractMutations(contractId: string) {
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
});
const extend = useMutation({
mutationFn: (payload: Freight.ExtendContractDto) =>
contractsService.extend(contractId, payload),
onSuccess: (data) =>
onSuccess(data, `Contract extended — it is back to ${data.status}`),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to extend contract")),
});
const approveStep = useMutation({
// The server derives the required role from the step itself, so the client
// does not send one.
@@ -280,6 +288,7 @@ export function useContractMutations(contractId: string) {
cancelByStaff,
suspend,
resume,
extend,
approveStep,
rejectStep,
generateContract,

View File

@@ -97,6 +97,7 @@ export const FREIGHT_PERMS = {
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
cancel: "edr_freight_app:contracts:cancel",
extend: "edr_freight_app:contracts:extend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
@@ -266,6 +267,13 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
trainCrew: {
view: "edr_freight_app:train_crew:view",
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
assign: "edr_freight_app:train_crew:assign",
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",

View File

@@ -206,6 +206,10 @@ export const LEGACY_APPROVAL_ROLES = [
const RATE_APPLIES_TO = [
{ label: "Bulk (base freight)", value: "BULK" },
{ label: "Container (base freight)", value: "CONTAINER" },
{
label: "Empty container (base freight, import)",
value: "EMPTY_CONTAINER",
},
{ label: "Intercity (base freight)", value: "INTERCITY" },
{ label: "First mile", value: "FIRST_MILE" },
{ label: "Last mile", value: "LAST_MILE" },
@@ -290,7 +294,9 @@ const SHIPPING_LINE_CARGO_KINDS = [
/** True when the rate being edited is base rail freight, which is priced per leg. */
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
["BULK", "CONTAINER", "EMPTY_CONTAINER", "INTERCITY"].includes(
String(values.appliesTo ?? ""),
);
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
@@ -388,6 +394,9 @@ const unitsForShape = (
switch (appliesTo) {
case "CONTAINER":
return ["PER_CONTAINER", "PER_WAGON"];
case "EMPTY_CONTAINER":
// No cargo to weigh — only the box and the wagon it rides on.
return ["PER_CONTAINER", "PER_WAGON"];
case "BULK":
return ["PER_TON", "PER_WAGON"];
case "INTERCITY":
@@ -1144,6 +1153,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
{
key: "empty-container",
label: "Empty container",
filters: { appliesTo: "EMPTY_CONTAINER", isShippingLineRate: "false" },
},
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
{
key: "trucking",
@@ -1301,8 +1315,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
// Empty freight and the empty-return surcharge are both import-only.
String(v.appliesTo ?? "") === "EMPTY_CONTAINER" ||
(String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN")
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
@@ -1310,7 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
!isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(["BULK", "CONTAINER", "EMPTY_CONTAINER"].includes(
String(v.appliesTo ?? ""),
) ||
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
@@ -1513,6 +1531,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
},
// Empty freight has no cargo to narrow by, so the box size IS the scope —
// required here, unlike the laden catch-all above. The API rejects an
// unscoped empty rate for the same reason.
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this rate covers",
description: "20ft and 40ft price differently — one rate per size per lane.",
showIf: (v) =>
!isShippingLineRate(v) && v.appliesTo === "EMPTY_CONTAINER",
},
// Container type for a shipping-line base-freight rate. Required here,
// unlike the customer form's optional catch-all: a line negotiates a
// price per box size, so an unscoped line rate has no meaning.

View File

@@ -0,0 +1,459 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Pencil, Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
// Generic list footer — shared by the fleet and train-scheduling lists despite
// the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import {
TRAIN_CREW_NATIONALITY_OPTIONS,
TRAIN_CREW_ROLE_OPTIONS,
TRAIN_CREW_STATUS_OPTIONS,
trainCrewNationalityLabel,
trainCrewRoleLabel,
trainCrewService,
trainCrewStatusLabel,
type SaveTrainCrewMemberPayload,
type TrainCrewMember,
type TrainCrewNationality,
type TrainCrewRole,
type TrainCrewStatus,
} from "@/services/trainCrew.service";
const DEFAULT_PAGE_SIZE = 10;
const ALL = "__all__";
/** Mantine colour per status, so the roster reads at a glance. */
const STATUS_COLOR: Record<TrainCrewStatus, string> = {
ACTIVE: "green",
INACTIVE: "gray",
SUSPENDED: "red",
ON_LEAVE: "yellow",
};
type FormState = {
firstName: string;
lastName: string;
role: TrainCrewRole | "";
nationality: TrainCrewNationality | "";
status: TrainCrewStatus;
isActive: boolean;
};
const EMPTY_FORM: FormState = {
firstName: "",
lastName: "",
role: "",
nationality: "",
status: "ACTIVE",
isActive: true,
};
export default function TrainCrewPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canCreate = hasPermission(user, FREIGHT_PERMS.trainCrew.create);
const canUpdate = hasPermission(user, FREIGHT_PERMS.trainCrew.update);
const canDelete = hasPermission(user, FREIGHT_PERMS.trainCrew.delete);
// The footer owns page size as well as page, so both live here. `pageIndex`
// is 0-based to match the footer's PaginationState; the API is 1-based.
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>(ALL);
const [nationalityFilter, setNationalityFilter] = useState<string>(ALL);
const [statusFilter, setStatusFilter] = useState<string>(ALL);
const [modalOpen, setModalOpen] = useState(false);
/** Row being edited; null means the modal is in create mode. */
const [editing, setEditing] = useState<TrainCrewMember | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<TrainCrewMember | null>(null);
// Filtering and paging are server-side, so the active filters are part of the
// query key — changing one refetches rather than slicing a stale page.
const filters = useMemo(
() => ({
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
...(search.trim() ? { search: search.trim() } : {}),
...(roleFilter !== ALL ? { role: roleFilter as TrainCrewRole } : {}),
...(nationalityFilter !== ALL
? { nationality: nationalityFilter as TrainCrewNationality }
: {}),
...(statusFilter !== ALL ? { status: statusFilter as TrainCrewStatus } : {}),
}),
[pagination, search, roleFilter, nationalityFilter, statusFilter],
);
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.TRAIN_CREW.list(filters),
queryFn: async () => {
const res = await trainCrewService.getAll(filters);
return res.data;
},
});
const members = data?.data ?? [];
const total = data?.total ?? 0;
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_CREW.ROOT });
const describeError = (error: unknown, fallback: string): string => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
return typeof message === "string" ? message : fallback;
};
const saveMutation = useMutation({
mutationFn: async (values: FormState) => {
const payload: Partial<SaveTrainCrewMemberPayload> = {
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
role: values.role as TrainCrewRole,
nationality: values.nationality as TrainCrewNationality,
status: values.status,
isActive: values.isActive,
};
return editing
? trainCrewService.update(editing.id, payload)
: trainCrewService.create(payload);
},
onSuccess: () => {
toast({ title: editing ? "Crew member updated" : "Crew member added" });
closeModal();
invalidate();
},
onError: (error: unknown) => {
toast({
title: editing ? "Could not update crew member" : "Could not add crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => trainCrewService.delete(id),
onSuccess: () => {
toast({ title: "Crew member removed" });
setDeleteTarget(null);
invalidate();
},
onError: (error: unknown) => {
toast({
title: "Could not remove crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (member: TrainCrewMember) => {
setEditing(member);
setForm({
firstName: member.firstName,
lastName: member.lastName,
role: member.role,
nationality: member.nationality,
status: member.status,
isActive: member.isActive,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
};
/** Every column is NOT NULL server-side, so all four must be filled. */
const formValid =
form.firstName.trim().length > 0 &&
form.lastName.trim().length > 0 &&
form.role !== "" &&
form.nationality !== "";
// Filters narrow the result set, so a page beyond the new last page would
// render empty — reset to the first page whenever one changes.
const resetToFirstPage = () =>
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
const onFilterChange = (setter: (value: string) => void) => (value: string | null) => {
setter(value ?? ALL);
resetToFirstPage();
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Train Crew" }, { label: "Crew Members" }]} />
<Group justify="space-between" mb="lg">
<div>
<Title order={1}>Train Crew</Title>
<Text size="sm" c="dimmed">
Roster of on-board personnel assignable to a train
</Text>
</div>
{canCreate ? (
<Button leftSection={<Plus size={16} />} onClick={openCreate} color="edr-green">
Add Crew Member
</Button>
) : null}
</Group>
<Card withBorder>
<Group p="md" gap="sm" align="flex-end" wrap="wrap">
<TextInput
label="Search"
placeholder="Search by name…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetToFirstPage();
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
label="Role"
data={[{ label: "All roles", value: ALL }, ...TRAIN_CREW_ROLE_OPTIONS]}
value={roleFilter}
onChange={onFilterChange(setRoleFilter)}
style={{ minWidth: 180 }}
/>
<Select
label="Nationality"
data={[
{ label: "All nationalities", value: ALL },
...TRAIN_CREW_NATIONALITY_OPTIONS,
]}
value={nationalityFilter}
onChange={onFilterChange(setNationalityFilter)}
style={{ minWidth: 170 }}
/>
<Select
label="Status"
data={[{ label: "All statuses", value: ALL }, ...TRAIN_CREW_STATUS_OPTIONS]}
value={statusFilter}
onChange={onFilterChange(setStatusFilter)}
style={{ minWidth: 160 }}
/>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>First Name</Table.Th>
<Table.Th>Last Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Nationality</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : members.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No crew members found.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{members.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>{member.firstName}</Table.Td>
<Table.Td>{member.lastName}</Table.Td>
<Table.Td>{trainCrewRoleLabel(member.role)}</Table.Td>
<Table.Td>{trainCrewNationalityLabel(member.nationality)}</Table.Td>
<Table.Td>
<Badge size="sm" color={STATUS_COLOR[member.status] ?? "gray"}>
{trainCrewStatusLabel(member.status)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" color={member.isActive ? "green" : "gray"} variant="light">
{member.isActive ? "Yes" : "No"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
{canUpdate ? (
<Button
size="xs"
variant="light"
leftSection={<Pencil size={14} />}
onClick={() => openEdit(member)}
>
Edit
</Button>
) : null}
{canDelete ? (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setDeleteTarget(member)}
>
Delete
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
itemLabel="crew members"
pagination={pagination}
pageCount={Math.ceil(total / pagination.pageSize)}
totalCount={total}
onPaginationChange={setPagination}
/>
</Card>
<Modal
opened={modalOpen}
onClose={closeModal}
title={editing ? "Edit Crew Member" : "Add Crew Member"}
size="lg"
>
<Stack gap="md">
<TextInput
label="First Name"
placeholder="First name"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.currentTarget.value })}
required
/>
<TextInput
label="Last Name"
placeholder="Last name"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.currentTarget.value })}
required
/>
<Select
label="Role"
placeholder="Select role"
data={TRAIN_CREW_ROLE_OPTIONS}
value={form.role || null}
onChange={(val) => setForm({ ...form, role: (val as TrainCrewRole) ?? "" })}
required
/>
<Select
label="Nationality"
placeholder="Select nationality"
data={TRAIN_CREW_NATIONALITY_OPTIONS}
value={form.nationality || null}
onChange={(val) =>
setForm({ ...form, nationality: (val as TrainCrewNationality) ?? "" })
}
required
/>
<Select
label="Status"
data={TRAIN_CREW_STATUS_OPTIONS}
value={form.status}
onChange={(val) =>
setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" })
}
required
/>
<Switch
label="Active"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={closeModal}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate(form)}
loading={saveMutation.isPending}
disabled={!formValid}
>
{editing ? "Save Changes" : "Add Crew Member"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title="Remove Crew Member"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew
roster?
</Text>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,612 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Stepper,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
Plus,
ShieldCheck,
Train,
Trash2,
Users,
Wrench,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
trainCrewService,
trainCrewRoleLabel,
type TrainCrewMember,
type TrainCrewRole,
} from "@/services/trainCrew.service";
import {
DUTY_ROLE_OPTIONS,
trainCrewAssignmentService,
type CorridorYard,
type CrewDutyRole,
} from "@/services/trainCrewAssignment.service";
/**
* One driver row being built. The leg (two yards) and the duty role are
* properties of THIS run, not of the person.
*/
interface DriverRow {
key: string;
crewMemberId: string | null;
fromYardId: string | null;
toYardId: string | null;
dutyRole: CrewDutyRole | null;
}
/**
* A new row pre-filled with the schedule's own endpoints — the common case is
* one driver over the whole route, and staff narrow it from there.
*/
const newDriverRow = (yards: CorridorYard[]): DriverRow => ({
key: `driver-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
crewMemberId: null,
fromYardId: yards[0]?.id ?? null,
toYardId: yards[yards.length - 1]?.id ?? null,
dutyRole: null,
});
/**
* Assign a train crew to one schedule — ITLMS Rolling Stock §1.1 and §1.2.
*
* Crew sizes are free-form: operations add as many drivers, police, technicians
* or specialists as a given run needs, rather than filling the fixed pairing
* cases of §2. What is still enforced is what makes a run coherent — every
* driver carries a leg and duty role, one Primary per leg, Djibouti drivers
* confined to Dire Dawa and eastward (§1.1), and the specialized crew the cargo
* actually demands (§1.2).
*
* A partial crew always saves: §1.2 puts the hard gate at departure, so this
* page and the dispatch guard call the same server-side validator.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trainCrew.assign);
const [step, setStep] = useState(0);
const [drivers, setDrivers] = useState<DriverRow[]>([]);
/** Support and specialist picks, keyed by role. */
const [supportIds, setSupportIds] = useState<Record<string, Array<string | null>>>({});
const { data: crew, isLoading } = useQuery({
queryKey: ["schedule-crew", scheduleId],
queryFn: async () => (await trainCrewAssignmentService.get(scheduleId)).data,
enabled: Boolean(scheduleId),
});
const { data: roster = [] } = useQuery({
queryKey: ["train-crew", "roster-all"],
queryFn: async () => {
const res = await trainCrewService.getAll({ limit: 200, status: "ACTIVE" });
return res.data.data;
},
});
// Seed from what is already saved, so reopening resumes rather than restarts.
useEffect(() => {
if (!crew) return;
const driverRows: DriverRow[] = [];
const support: Record<string, Array<string | null>> = {};
for (const a of crew.assignments) {
if (a.role === "TRAIN_DRIVER") {
driverRows.push({
key: a.id,
crewMemberId: a.crewMemberId,
fromYardId: a.fromYardId ?? null,
toYardId: a.toYardId ?? null,
dutyRole: a.dutyRole ?? null,
});
} else {
support[a.role] = [...(support[a.role] ?? []), a.crewMemberId];
}
}
setDrivers(driverRows);
setSupportIds(support);
}, [crew]);
const corridorYards = crew?.corridorYards ?? [];
const yardOptions = useMemo(
() => corridorYards.map((y) => ({ value: y.id, label: y.label })),
[corridorYards],
);
/**
* §1.1 — a leg is open to a Djibouti driver only when both ends sit at or
* beyond Dire Dawa. Position along the corridor answers this without naming
* station pairs, so a handover anywhere east of Dire Dawa works.
*/
const legOpenToDjibouti = (leg: {
fromYardId: string | null;
toYardId: string | null;
}) => {
const boundary = corridorYards.find((y) => /dire dawa/i.test(y.label));
const from = corridorYards.find((y) => y.id === leg.fromYardId);
const to = corridorYards.find((y) => y.id === leg.toYardId);
// An unknown boundary or half-built leg is not a breach — the server-side
// validator reports the incomplete leg on its own.
if (!boundary || !from || !to) return true;
return Math.min(from.displayOrder, to.displayOrder) >= boundary.displayOrder;
};
const byRole = useMemo(() => {
const map = new Map<TrainCrewRole, TrainCrewMember[]>();
for (const m of roster) {
map.set(m.role, [...(map.get(m.role) ?? []), m]);
}
return map;
}, [roster]);
/** Everyone already picked — nobody may hold two seats on one run. */
const takenIds = useMemo(() => {
const ids = [
...drivers.map((d) => d.crewMemberId),
...Object.values(supportIds).flat(),
].filter(Boolean) as string[];
return new Set(ids);
}, [drivers, supportIds]);
const memberOptions = (
role: TrainCrewRole,
currentValue: string | null,
leg?: { fromYardId: string | null; toYardId: string | null },
) =>
(byRole.get(role) ?? [])
.filter((m) => {
// §1.1 territorial boundary: a Djibouti driver never appears on a leg
// they may not work. Enforced by making the invalid choice unavailable
// rather than by rejecting it afterwards.
if (leg && m.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(leg)) {
return false;
}
return m.id === currentValue || !takenIds.has(m.id);
})
.map((m) => ({
value: m.id,
label: `${m.firstName} ${m.lastName} · ${m.nationality === "ETHIOPIAN" ? "ET" : "DJ"}`,
}));
const setDriver = (key: string, patch: Partial<DriverRow>) =>
setDrivers((prev) =>
prev.map((row) => {
if (row.key !== key) return row;
const next = { ...row, ...patch };
// Moving the leg can invalidate the person already chosen — clear
// rather than silently persist a territorial breach.
const legMoved = patch.fromYardId !== undefined || patch.toYardId !== undefined;
if (legMoved && next.crewMemberId) {
const member = roster.find((m) => m.id === next.crewMemberId);
if (member?.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(next)) {
next.crewMemberId = null;
}
}
return next;
}),
);
const setSupportCount = (role: TrainCrewRole, count: number) =>
setSupportIds((prev) => ({
...prev,
[role]: Array.from({ length: count }, (_, i) => prev[role]?.[i] ?? null),
}));
const buildPayload = () => {
const assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}> = [];
for (const row of drivers) {
if (row.crewMemberId) {
assignments.push({
crewMemberId: row.crewMemberId,
role: "TRAIN_DRIVER",
...(row.dutyRole ? { dutyRole: row.dutyRole } : {}),
...(row.fromYardId ? { fromYardId: row.fromYardId } : {}),
...(row.toYardId ? { toYardId: row.toYardId } : {}),
});
}
}
for (const [role, ids] of Object.entries(supportIds)) {
for (const id of ids) {
if (id) assignments.push({ crewMemberId: id, role: role as TrainCrewRole });
}
}
return { assignments };
};
const saveMutation = useMutation({
mutationFn: () => trainCrewAssignmentService.save(scheduleId, buildPayload()),
onSuccess: (res) => {
const validation = res.data;
toast({
title: validation.complete
? "Crew saved — composition complete"
: "Crew saved (still incomplete)",
description: validation.complete
? undefined
: "The train cannot be dispatched until every rule passes.",
});
qc.invalidateQueries({ queryKey: ["schedule-crew", scheduleId] });
},
onError: (error: unknown) => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
toast({
title: "Could not save crew",
description: Array.isArray(message)
? message.join(", ")
: typeof message === "string"
? message
: "The request failed. Please try again.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
const demand = crew?.demand;
const specialized = crew?.requirements.specialized ?? [];
const technicianRule = crew?.requirements.technician;
const validation = crew?.validation;
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
subtitle="Add as many drivers and crew as this run needs"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
meta={
validation ? (
<Badge
variant="light"
color={validation.complete ? "green" : "orange"}
leftSection={
validation.complete ? <CheckCircle2 size={12} /> : <AlertTriangle size={12} />
}
>
{validation.complete ? "Ready to dispatch" : "Incomplete"}
</Badge>
) : null
}
action={
canAssign ? (
<Button
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
color="edr-green"
>
Save Crew
</Button>
) : null
}
/>
<Stepper active={step} onStepClick={setStep} mt="md" size="sm">
<Stepper.Step label="Drivers" description="Any number">
<Stack gap="md" mt="lg">
<Text size="sm" c="dimmed">
Add a row per driver and set the leg they work any two yards on this
schedule's route, so a handover at Feto or Meiso is as easy as one at
Dire Dawa. Djibouti drivers are offered only on legs from Dire Dawa
eastward.
</Text>
{drivers.length === 0 ? (
<Alert color="gray">No drivers added yet.</Alert>
) : (
drivers.map((row, index) => (
<Card key={row.key} withBorder padding="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
<Train size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Driver {index + 1}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove driver"
onClick={() =>
setDrivers((prev) => prev.filter((d) => d.key !== row.key))
}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Group grow align="flex-start" wrap="wrap">
<Select
label="From yard"
placeholder="Start of this leg"
searchable
data={yardOptions}
value={row.fromYardId}
onChange={(val) => setDriver(row.key, { fromYardId: val })}
/>
<Select
label="To yard"
placeholder="End of this leg"
searchable
data={yardOptions}
value={row.toYardId}
onChange={(val) => setDriver(row.key, { toYardId: val })}
/>
<Select
label="Duty role"
placeholder="Select a duty role"
data={DUTY_ROLE_OPTIONS}
value={row.dutyRole}
onChange={(val) =>
setDriver(row.key, { dutyRole: (val as CrewDutyRole) ?? null })
}
/>
<Select
label="Driver"
placeholder="Select a driver"
searchable
clearable
data={memberOptions("TRAIN_DRIVER", row.crewMemberId, row)}
value={row.crewMemberId}
onChange={(val) => setDriver(row.key, { crewMemberId: val })}
/>
</Group>
</Card>
))
)}
<Button
variant="light"
leftSection={<Plus size={16} />}
onClick={() => setDrivers((prev) => [...prev, newDriverRow(corridorYards)])}
>
Add Driver
</Button>
</Stack>
</Stepper.Step>
<Stepper.Step label="Support crew" description="Police, technical, cargo">
<Stack gap="lg" mt="lg">
<SupportSection
role="FEDERAL_POLICE"
icon={<ShieldCheck size={16} />}
color="blue"
title="Security detail"
hint="Add as many federal police as this run needs"
values={supportIds.FEDERAL_POLICE ?? []}
onCount={(n) => setSupportCount("FEDERAL_POLICE", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
FEDERAL_POLICE: (prev.FEDERAL_POLICE ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions("FEDERAL_POLICE", value)}
/>
<SupportSection
role="TECHNICIAN"
icon={<Wrench size={16} />}
color="orange"
title="Technical maintenance crew"
hint={technicianRule?.reason ?? "Optional technical maintenance crew"}
alert={
demand?.hasBadOrderWagon
? "A defective wagon is attached, so at least one technician is mandatory."
: undefined
}
values={supportIds.TECHNICIAN ?? []}
onCount={(n) => setSupportCount("TECHNICIAN", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
TECHNICIAN: (prev.TECHNICIAN ?? []).map((v, idx) => (idx === i ? val : v)),
}))
}
options={(value) => memberOptions("TECHNICIAN", value)}
/>
{specialized.length ? (
specialized.map((rule) => (
<SupportSection
key={rule.role}
role={rule.role}
icon={<Users size={16} />}
color="grape"
title={trainCrewRoleLabel(rule.role)}
hint={rule.reason}
values={supportIds[rule.role] ?? []}
onCount={(n) => setSupportCount(rule.role, n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
[rule.role]: (prev[rule.role] ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions(rule.role, value)}
/>
))
) : (
<Alert color="gray">
No specialized cargo detected on this train no reefer, HAZMAT, break-bulk
or livestock crew is required.
</Alert>
)}
</Stack>
</Stepper.Step>
<Stepper.Completed>
<Stack gap="md" mt="lg">
<Card withBorder padding="lg">
<Text fw={600} mb="sm">
Composition checklist
</Text>
{validation?.complete ? (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="green" variant="light">
<CheckCircle2 size={14} />
</ThemeIcon>
<Text size="sm">Every rule passes this train may be dispatched.</Text>
</Group>
) : (
<Stack gap="xs">
{validation?.issues.map((issue) => (
<Group key={`${issue.code}-${issue.message}`} gap="xs" wrap="nowrap">
<ThemeIcon size={22} radius="xl" color="orange" variant="light">
<AlertTriangle size={14} />
</ThemeIcon>
<Text size="sm">{issue.message}</Text>
</Group>
))}
</Stack>
)}
</Card>
{validation?.runType ? (
<Text size="sm" c="dimmed">
Derived run type:{" "}
<Text span fw={600}>
{validation.runType === "LONG_RUN" ? "Long run" : "Short run"}
</Text>
</Text>
) : null}
</Stack>
</Stepper.Completed>
</Stepper>
<Group justify="space-between" mt="xl">
<Button variant="light" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
<Button variant="light" disabled={step > 1} onClick={() => setStep((s) => s + 1)}>
Next
</Button>
</Group>
</PageContainer>
);
}
/** A crew block: add/remove rows freely, each naming one person. */
function SupportSection({
role,
icon,
color,
title,
hint,
alert,
values,
onCount,
onPick,
options,
}: {
role: TrainCrewRole;
icon: React.ReactNode;
color: string;
title: string;
hint: string;
alert?: string;
values: Array<string | null>;
onCount: (count: number) => void;
onPick: (index: number, value: string | null) => void;
options: (currentValue: string | null) => Array<{ value: string; label: string }>;
}) {
return (
<Card withBorder padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size={32} radius="md" variant="light" color={color}>
{icon}
</ThemeIcon>
<div>
<Text fw={600}>{title}</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</div>
</Group>
{alert ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} mb="md">
{alert}
</Alert>
) : null}
<Stack gap="sm">
{values.map((value, index) => (
<Group key={index} align="flex-end" wrap="nowrap">
<Select
label={`${trainCrewRoleLabel(role)} ${index + 1}`}
placeholder="Select a crew member"
searchable
clearable
data={options(value)}
value={value}
onChange={(val) => onPick(index, val)}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove"
onClick={() => {
const next = values.filter((_, i) => i !== index);
onCount(next.length);
next.forEach((v, i) => onPick(i, v));
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => onCount(values.length + 1)}
style={{ alignSelf: "flex-start" }}
>
Add {trainCrewRoleLabel(role)}
</Button>
</Stack>
</Card>
);
}

View File

@@ -42,6 +42,7 @@ import {
Ruler,
Send,
Train,
Unlock,
Weight,
Workflow as WorkflowIcon,
Warehouse,
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
// Reopen a train whose booking shut only because of its close offset.
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -1325,6 +1329,19 @@ export default function TrainScheduleV2DetailPage() {
}
action={
<Group gap="sm" wrap="nowrap">
{/* Booking shut only by the close offset — the one closed state
staff can undo here, so it gets a visible button. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="compact-sm"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reduce close offset
</Button>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
@@ -1441,6 +1458,15 @@ export default function TrainScheduleV2DetailPage() {
Window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
@@ -1820,6 +1846,13 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={scheduleId ?? null}
opened={closeOffsetOpen}
onClose={() => setCloseOffsetOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<LoadEmptyContainersModal
opened={loadEmptiesOpen}
onClose={() => setLoadEmptiesOpen(false)}

View File

@@ -37,6 +37,8 @@ import {
Send,
Table2,
Train,
Unlock,
Users,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -57,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
@@ -144,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// "Shorten close offset": reopens a train whose booking shut only because
// of its close offset (the API flags exactly those rows).
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
@@ -372,12 +378,40 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
size: 32,
size: 330,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
return (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{/* Booking shut only by the close offset: a visible button, since
this is the one closed state staff can fix from the board. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="xs"
radius="md"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reduce offset
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="xs"
radius="md"
leftSection={<Users size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`,
)
}
>
Assign Train Crew
</Button>
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
@@ -419,6 +453,15 @@ export default function TrainScheduleV2ListPage() {
Booking window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
@@ -639,6 +682,9 @@ export default function TrainScheduleV2ListPage() {
onTrack={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
onAssignCrew={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`)
}
/>
))}
</SimpleGrid>
@@ -787,6 +833,13 @@ export default function TrainScheduleV2ListPage() {
onSaved={() => void schedulesQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={closeOffsetId}
opened={closeOffsetId != null}
onClose={() => setCloseOffsetId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}
@@ -1053,10 +1106,12 @@ function ScheduleCard({
schedule,
onOpen,
onTrack,
onAssignCrew,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
onTrack: () => void;
onAssignCrew: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -1153,6 +1208,19 @@ function ScheduleCard({
Track
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="sm"
radius="md"
leftSection={<Users size={15} />}
onClick={(e) => {
e.stopPropagation();
onAssignCrew();
}}
>
Assign Train Crew
</Button>
</Group>
</Stack>
</Card>

View File

@@ -29,9 +29,14 @@ import {
ArrowUp,
ChartColumn,
ChevronRight,
CircleCheck,
List,
MapPin,
PauseCircle,
Search,
TrainFront,
Wrench,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -48,6 +53,17 @@ import {
} from "./wagonPerformance";
import { downloadSheet, downloadSheets } from "./exportSection";
import { SectionExportButton } from "./SectionExportButton";
import {
CardHeader,
ColumnChart,
LegendKey,
LegendRow,
SplitBar,
StackedBar,
formatCount,
toneVar,
} from "./chartKit";
import "./wagonPerformance.css";
const WINDOWS = [
{ value: "30", label: "30d" },
@@ -89,25 +105,78 @@ const IDLE_BUCKETS: Array<{
{ label: "46 d +", min: 46, max: Infinity, tone: "red" },
];
/**
* One headline figure.
*
* The tone rail down the left edge is the tile's status channel — it repeats
* what the value's colour already says, so severity survives for a reader who
* cannot separate the hues. `meter` is an optional share of the fleet, drawn
* on a track one step lighter than its own fill so the whole bar reads.
*/
const StatTile = ({
label,
value,
hint,
color,
tone = "gray",
icon: Icon,
meter,
}: {
label: string;
value: React.ReactNode;
hint: string;
color?: string;
tone?: string;
icon?: LucideIcon;
meter?: number;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="26px" fw={700} lh={1.1} mt={8} c={color}>
<Card
withBorder
radius="lg"
padding="md"
pl="lg"
className="wp-stat-tile"
style={{ position: "relative", overflow: "hidden" }}
>
<Box
style={{
position: "absolute",
insetBlock: 0,
left: 0,
width: 3,
background: toneVar(tone),
}}
/>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} lh={1.3}>
{label}
</Text>
{Icon ? <Icon size={15} strokeWidth={2} color={toneVar(tone)} /> : null}
</Group>
<Text size="30px" fw={700} lh={1.05} mt={10} c={color}>
{value}
</Text>
<Text size="xs" c="dimmed" mt={6} lh={1.35}>
{meter == null ? null : (
<Box
mt={12}
h={4}
style={{
borderRadius: 999,
background: toneVar(tone, 1),
overflow: "hidden",
}}
>
<Box
h="100%"
w={`${Math.min(100, Math.max(0, meter))}%`}
style={{ borderRadius: 999, background: toneVar(tone) }}
/>
</Box>
)}
<Text size="xs" c="dimmed" mt={meter == null ? 8 : 8} lh={1.35}>
{hint}
</Text>
</Card>
@@ -140,7 +209,13 @@ const SortHeader = ({
</UnstyledButton>
);
/** A short ranked list — the "best / worst" boards. */
/**
* A short ranked list — the "best / worst" boards.
*
* Each row carries a hairline bar scaled against the board's own leader, so
* the shape of the ranking (a runaway top wagon, or a flat field) is visible
* without reading every figure. Rows are buttons: they open the wagon.
*/
const Leaderboard = ({
title,
subtitle,
@@ -151,69 +226,114 @@ const Leaderboard = ({
title: string;
subtitle: string;
accent: string;
rows: Array<{ id: string; number: string; note: string; value: string }>;
rows: Array<{
id: string;
number: string;
note: string;
value: string;
weight?: number;
}>;
onOpen: (id: string) => void;
}) => (
<Card withBorder radius="md" padding={0}>
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap={8} wrap="nowrap">
<Box
w={7}
h={7}
style={{
borderRadius: 2,
background: `var(--mantine-color-${accent}-6)`,
}}
/>
}) => {
const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0));
return (
<Card withBorder radius="lg" padding={0} style={{ overflow: "hidden" }}>
<Box style={{ height: 3, background: toneVar(accent) }} />
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Text fw={600} size="sm">
{title}
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={9}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} c="dimmed" w={14}>
{i + 1}
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={10}
className="wp-rank-row"
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{/* Medallion: the top three carry the board's own tone. */}
<Center
w={20}
h={20}
style={{
flexShrink: 0,
borderRadius: 6,
background:
i < 3
? toneVar(accent, 0)
: "var(--mantine-color-edr-slate-soft-0)",
}}
>
<Text
size="10px"
fw={700}
c={i < 3 ? `${accent}.8` : "dimmed"}
lh={1}
>
{i + 1}
</Text>
</Center>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text
size="sm"
fw={700}
style={{
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{r.value}
</Text>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text size="sm" fw={700} style={{ whiteSpace: "nowrap" }}>
{r.value}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
{r.weight == null ? null : (
<Box
mt={8}
ml={30}
h={3}
style={{
borderRadius: 999,
background: "var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(2, (r.weight / peak) * 100)}%`}
style={{ borderRadius: 999, background: toneVar(accent) }}
/>
</Box>
)}
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
};
/**
* Wagon performance — the executive report on how the wagon fleet is earning
@@ -355,6 +475,9 @@ const WagonPerformancePage = () => {
.sort((a, b) => b.wagons - a.wagons);
}, [wagons]);
/** Busiest yard — the scale every yard's share bar is drawn against. */
const yardPeak = Math.max(1, ...byYard.map((y) => y.wagons));
/** Which classes of stock earn, and which sit. */
const byType = useMemo(() => {
const rows = new Map<
@@ -425,6 +548,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${w.loadsInWindow ?? 0} loads`,
weight: w.loadsInWindow ?? 0,
})),
stranded: withIdle
.sort((a, b) => b.idle - a.idle)
@@ -434,6 +558,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${idle} days`,
weight: idle,
})),
idle: [...wagons]
.filter((w) => (w.movesInWindow ?? 0) === 0)
@@ -721,12 +846,17 @@ const WagonPerformancePage = () => {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md">
<StatTile
label="Fleet size"
value={kpis.total}
value={formatCount(kpis.total)}
hint="Wagons on the register"
tone="edr-slate"
icon={TrainFront}
/>
<StatTile
label="In service"
value={kpis.inService}
value={formatCount(kpis.inService)}
tone="edr-green"
icon={CircleCheck}
meter={kpis.total > 0 ? (kpis.inService / kpis.total) * 100 : 0}
hint={
kpis.total > 0
? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet`
@@ -735,20 +865,28 @@ const WagonPerformancePage = () => {
/>
<StatTile
label={`Idle over ${IDLE_THRESHOLD_DAYS}d`}
value={kpis.stranded}
value={formatCount(kpis.stranded)}
hint="No movement in the current yard"
color={kpis.stranded > 0 ? "red" : undefined}
tone={kpis.stranded > 0 ? "red" : "edr-slate"}
icon={PauseCircle}
meter={kpis.total > 0 ? (kpis.stranded / kpis.total) * 100 : 0}
/>
<StatTile
label="Off roster"
value={kpis.offRoster}
value={formatCount(kpis.offRoster)}
hint="Maintenance, detained or withdrawn"
color={kpis.offRoster > 0 ? "yellow.8" : undefined}
tone={kpis.offRoster > 0 ? "yellow" : "edr-slate"}
icon={Wrench}
meter={kpis.total > 0 ? (kpis.offRoster / kpis.total) * 100 : 0}
/>
<StatTile
label="Loads · moves"
value={kpis.loads}
hint={`${kpis.moves} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
value={formatCount(kpis.loads)}
tone="edr-blue"
icon={ChartColumn}
hint={`${formatCount(kpis.moves)} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
/>
</SimpleGrid>
@@ -776,136 +914,88 @@ const WagonPerformancePage = () => {
<Stack gap="lg">
{/* ── Status mix + idle distribution ───────────── */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Status mix</Text>
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{kpis.total} wagons on the register
</Text>
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Status mix"
subtitle={`${kpis.total} wagons on the register`}
action={
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
}
/>
{statusMix.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
<Text size="sm" c="dimmed" py="xl" ta="center">
No wagons registered.
</Text>
) : (
<>
<Progress.Root size="lg" radius="xl" mt="md" mb="md">
<Box mt="lg" mb="lg">
<StackedBar
height={14}
unit="wagons"
segments={statusMix.map((s) => ({
key: s.status,
label: s.label,
value: s.count,
pct: s.pct,
tone: s.color,
}))}
/>
</Box>
<Stack gap={11}>
{statusMix.map((s) => (
<Progress.Section
<LegendRow
key={s.status}
value={s.pct}
color={s.color}
tone={s.color}
label={s.label}
value={s.count}
pct={s.pct}
/>
))}
</Progress.Root>
<Stack gap={9}>
{statusMix.map((s) => (
<Group
key={s.status}
justify="space-between"
gap="sm"
>
<Group gap={9} wrap="nowrap">
<Box
w={9}
h={9}
style={{
borderRadius: 3,
background: `var(--mantine-color-${s.color}-6)`,
}}
/>
<Text size="sm">{s.label}</Text>
</Group>
<Group gap="sm">
<Text size="sm" fw={700}>
{s.count}
</Text>
<Text size="xs" c="dimmed" w={34} ta="right">
{s.pct}%
</Text>
</Group>
</Group>
))}
</Stack>
</>
)}
</Card>
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Idle-day distribution</Text>
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Wagons by days without movement in their current yard
</Text>
<Group
align="flex-end"
gap="md"
h={170}
mt="lg"
wrap="nowrap"
>
{idleDistribution.map((b) => (
<Stack
key={b.label}
gap={6}
align="center"
justify="flex-end"
h="100%"
style={{ flex: 1 }}
>
<Text
size="xs"
fw={700}
c={
b.tone === "red"
? "red"
: b.tone === "yellow"
? "yellow.8"
: undefined
}
>
{b.count}
</Text>
<Box
w="100%"
h={`${Math.max(3, b.pct)}%`}
style={{
background: `var(--mantine-color-${b.tone}-6)`,
borderRadius: "5px 5px 0 0",
minHeight: 3,
}}
/>
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: "nowrap" }}
>
{b.label}
</Text>
</Stack>
))}
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Idle-day distribution"
subtitle="Wagons by days without movement in their current yard"
action={
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
}
/>
{/* The bar colours are a severity scale, not identity, so
the key names the bands rather than each bucket. */}
<Group gap="lg" mt="sm">
<LegendKey tone="edr-green" label="Healthy" />
<LegendKey tone="yellow" label="Watch" />
<LegendKey tone="red" label="Stranded" />
</Group>
<ColumnChart data={idleDistribution} />
<Text
size="xs"
c="dimmed"
mt="md"
mt="lg"
pt="sm"
style={{
borderTop:
"1px solid var(--mantine-color-edr-divider-0)",
}}
>
<strong>{kpis.stranded}</strong> wagons have sat over{" "}
{IDLE_THRESHOLD_DAYS} days
<Text
span
fw={700}
c={kpis.stranded > 0 ? "red" : undefined}
>
{kpis.stranded}
</Text>{" "}
wagons have sat over {IDLE_THRESHOLD_DAYS} days
{kpis.total > 0
? `${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up`
: ""}
@@ -947,29 +1037,30 @@ const WagonPerformancePage = () => {
</SimpleGrid>
{/* ── By yard ──────────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Box p="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>By yard</Text>
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Where the fleet is parked and how long it stays
</Text>
<Card withBorder radius="lg" padding={0}>
<Box p="lg" pb="md">
<CardHeader
title="By yard"
subtitle="Where the fleet is parked and how long it stays"
action={
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
}
/>
</Box>
{byYard.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
No wagons to group.
</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Yard</Table.Th>
<Table.Th w={200}>Share of fleet</Table.Th>
<Table.Th w={110} ta="right">
Wagons
</Table.Th>
@@ -985,12 +1076,60 @@ const WagonPerformancePage = () => {
{byYard.map((y) => (
<Table.Tr key={y.label}>
<Table.Td>
<Text size="sm" fw={600}>
{y.label}
</Text>
<Group gap={8} wrap="nowrap">
<MapPin
size={13}
color="var(--mantine-color-edr-muted-0)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={600}>
{y.label}
</Text>
</Group>
</Table.Td>
<Table.Td>
{/* Bar is scaled against the busiest yard, so
the biggest one always fills the track. */}
<Tooltip
withArrow
label={`${y.wagons} wagons · ${
kpis.total > 0
? Math.round(
(y.wagons / kpis.total) * 100,
)
: 0
}% of the fleet`}
>
<Box
h={6}
style={{
borderRadius: 999,
background:
"var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(
2,
(y.wagons / yardPeak) * 100,
)}%`}
style={{
borderRadius: 999,
background: toneVar("edr-blue"),
}}
/>
</Box>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
<Text
size="sm"
fw={600}
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{y.wagons}
</Text>
</Table.Td>
@@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => {
</Card>
{/* ── By wagon type ────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Card withBorder radius="lg" padding={0}>
<Group
p="lg"
pb="md"
justify="space-between"
wrap="wrap"
gap="sm"
>
<div>
<Text fw={600}>By wagon type</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => {
stuck
</Text>
</div>
<Group gap="md">
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-edr-green-6)",
}}
/>
<Text size="xs" c="dimmed">
Loaded
</Text>
</Group>
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-teal-4)",
}}
/>
<Text size="xs" c="dimmed">
Empty
</Text>
</Group>
<Group gap="lg">
<LegendKey tone="edr-green" label="Loaded" />
<LegendKey tone="teal.3" label="Empty" />
<SectionExportButton
label="by-type"
onExport={exportByType}
@@ -1122,21 +1243,23 @@ const WagonPerformancePage = () => {
</Table.Td>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Progress.Root
size="sm"
radius="xl"
style={{ flex: 1 }}
<Box style={{ flex: 1 }}>
<SplitBar
primaryPct={t.loadedPct}
secondaryPct={t.emptyPct}
primaryLabel="Loaded"
secondaryLabel="Empty"
/>
</Box>
<Text
size="xs"
fw={600}
w={34}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
<Progress.Section
value={t.loadedPct}
color="edr-green"
/>
<Progress.Section
value={t.emptyPct}
color="teal.4"
/>
</Progress.Root>
<Text size="xs" fw={600} w={34} ta="right">
{t.loadedPct}%
</Text>
</Group>
@@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => {
}
w={72}
/>
<Text size="sm" fw={600} w={38} ta="right">
<Text
size="sm"
fw={600}
w={38}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{share}%
</Text>
</Group>

View File

@@ -0,0 +1,380 @@
/**
* Presentation primitives for the wagon performance report.
*
* Pure display — every one of these takes numbers that are already derived
* and draws them. Kept apart from the page so the report's markup stays about
* what is being said, not about how a bar is rounded.
*
* House rules these encode (so charts across the report agree):
* · columns cap at 28px and never fill their slot — the leftover is air;
* · a data-end is rounded 4px, the baseline end stays square;
* · touching fills are separated by a 2px gap in the surface colour, never
* by a border — ink that is not data;
* · text wears text tokens; the colour lives on the mark beside it.
*/
import type { ReactNode } from "react";
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import "./wagonPerformance.css";
/**
* Thousands-separated count. Fleet figures run into four digits, and `1284`
* is read as a code rather than a quantity.
*/
export const formatCount = (n: number): string => n.toLocaleString("en-US");
/** One-step-off-surface hairline, for gridlines and baselines. */
export const GRID_LINE = "var(--mantine-color-edr-divider-0)";
/** Resolve a Mantine colour name (`edr-green`, `red.6`) to a CSS variable. */
export const toneVar = (tone: string, fallbackShade = 6): string => {
const [name, shade] = tone.split(".");
return `var(--mantine-color-${name}-${shade ?? fallbackShade})`;
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface SparkBarDatum {
label: string;
count: number;
/** Bar height as a share of the tallest bar, 0100. */
pct: number;
tone: string;
}
/**
* Column chart for a bucketed distribution.
*
* Bars sit on a real baseline with three recessive gridlines behind them, so
* a reader can judge a middle bar against a neighbour instead of guessing.
* Only the tallest column keeps a permanent value label; the rest carry theirs
* in the hover tooltip, because a number over every column stops being read.
*/
export const ColumnChart = ({
data,
height = 190,
unit = "wagons",
}: {
data: SparkBarDatum[];
height?: number;
unit?: string;
}) => {
const peak = Math.max(...data.map((d) => d.count), 0);
return (
<Box mt="lg">
<Box style={{ position: "relative", height, marginTop: 18 }}>
{/* Gridlines at the peak and two even steps below it, behind the
bars. The peak line doubles as the chart's top edge, so the
tallest column reads as touching it rather than floating. */}
{[0, 1, 2].map((i) => (
<Box
key={i}
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(i * 100) / 3}%`,
borderTop: `1px solid ${GRID_LINE}`,
pointerEvents: "none",
}}
/>
))}
<Group
align="flex-end"
gap="xs"
h="100%"
wrap="nowrap"
className="wp-col-chart"
style={{ position: "relative" }}
>
{data.map((d) => {
const isPeak = d.count === peak && peak > 0;
return (
<Tooltip
key={d.label}
withArrow
label={`${d.label} · ${d.count} ${unit}`}
>
<Stack
gap={0}
align="center"
justify="flex-end"
h="100%"
className="wp-col-slot"
style={{ flex: 1, cursor: "default" }}
>
{/* The label is absolutely positioned above its bar so it
never eats the bar's own height — otherwise the tallest
column can never reach the peak gridline. */}
<Box
w="100%"
maw={28}
h={`${Math.max(2, d.pct)}%`}
className="wp-col-bar"
style={{
position: "relative",
background: toneVar(d.tone),
borderRadius: "4px 4px 0 0",
minHeight: 2,
transition: "opacity 120ms ease",
}}
>
{isPeak ? (
<Text
size="xs"
fw={700}
lh={1}
ta="center"
style={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translateX(-50%)",
marginBottom: 5,
}}
>
{d.count}
</Text>
) : null}
</Box>
</Stack>
</Tooltip>
);
})}
</Group>
</Box>
{/* Baseline: one weight heavier than the gridlines, so zero reads. */}
<Box
style={{ borderTop: `1px solid var(--mantine-color-edr-border-0)` }}
/>
<Group gap="xs" wrap="nowrap" mt={8}>
{data.map((d) => (
<Text
key={d.label}
size="xs"
c="dimmed"
ta="center"
style={{ flex: 1, whiteSpace: "nowrap" }}
>
{d.label}
</Text>
))}
</Group>
</Box>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface StackSegment {
key: string;
label: string;
value: number;
/** Segment width as a share of the whole, 0100. */
pct: number;
tone: string;
}
/**
* A single stacked proportion bar.
*
* Segments are separated by a 2px gap in the surface colour rather than a
* stroke, so neighbouring shades stay distinct without extra ink. Every
* segment is hoverable; none is labelled inline, since interior segments have
* no free end to label without clipping.
*/
export const StackedBar = ({
segments,
height = 12,
unit = "",
}: {
segments: StackSegment[];
height?: number;
unit?: string;
}) => (
<Group gap={2} wrap="nowrap" style={{ width: "100%" }}>
{segments
.filter((s) => s.value > 0)
.map((s, i, shown) => (
<Tooltip
key={s.key}
withArrow
label={`${s.label} · ${s.value}${unit ? ` ${unit}` : ""} (${s.pct}%)`}
>
<Box
h={height}
style={{
// Flex-grow by share, but never vanish: a 1-wagon status still
// needs a visible sliver to be hoverable.
flex: `${Math.max(s.pct, 0.5)} 1 0`,
minWidth: 3,
background: toneVar(s.tone),
borderRadius:
shown.length === 1
? 999
: i === 0
? "999px 2px 2px 999px"
: i === shown.length - 1
? "2px 999px 999px 2px"
: 2,
cursor: "default",
}}
/>
</Tooltip>
))}
</Group>
);
/* ────────────────────────────────────────────────────────────────────────── */
/**
* Two-tone split bar for a loaded / empty style mix, sized inside a table row.
* The unfilled remainder is a lighter step of the same ramp, so the whole
* track carries state rather than only the filled part.
*/
export const SplitBar = ({
primaryPct,
secondaryPct,
primaryTone = "edr-green",
secondaryTone = "teal.3",
primaryLabel,
secondaryLabel,
}: {
primaryPct: number;
secondaryPct: number;
primaryTone?: string;
secondaryTone?: string;
primaryLabel: string;
secondaryLabel: string;
}) => {
const both = primaryPct > 0 && secondaryPct > 0;
// A zero-value side is dropped entirely rather than shown as a sliver —
// a 1px nub of the wrong colour on a 100% bar reads as bad data.
return (
<Group gap={both ? 2 : 0} wrap="nowrap" style={{ width: "100%" }}>
{primaryPct > 0 ? (
<Tooltip withArrow label={`${primaryLabel} · ${primaryPct}%`}>
<Box
h={8}
style={{
flex: `${primaryPct} 1 0`,
minWidth: 3,
background: toneVar(primaryTone),
borderRadius: both ? "999px 2px 2px 999px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{secondaryPct > 0 ? (
<Tooltip withArrow label={`${secondaryLabel} · ${secondaryPct}%`}>
<Box
h={8}
style={{
flex: `${secondaryPct} 1 0`,
minWidth: 3,
background: toneVar(secondaryTone),
borderRadius: both ? "2px 999px 999px 2px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{/* Nothing moved at all — an empty track, so the row still has a shape. */}
{primaryPct === 0 && secondaryPct === 0 ? (
<Box
h={8}
style={{
flex: 1,
background: "var(--mantine-color-edr-divider-0)",
borderRadius: 999,
}}
/>
) : null}
</Group>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
/** Legend swatch + label + value, the identity channel beside every chart. */
export const LegendRow = ({
tone,
label,
value,
pct,
}: {
tone: string;
label: string;
value: ReactNode;
pct?: number;
}) => (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={8}
h={8}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="sm" truncate>
{label}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
{value}
</Text>
{pct == null ? null : (
<Text
size="xs"
c="dimmed"
w={34}
ta="right"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{pct}%
</Text>
)}
</Group>
</Group>
);
/** Small square colour key used in a card header's inline legend. */
export const LegendKey = ({ tone, label }: { tone: string; label: string }) => (
<Group gap={6} wrap="nowrap">
<Box
w={10}
h={10}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
/** A card's title block: name, one line of context, and its own actions. */
export const CardHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<div style={{ minWidth: 0 }}>
<Text fw={600}>{title}</Text>
{subtitle ? (
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
) : null}
</div>
{action}
</Group>
);

View File

@@ -0,0 +1,46 @@
/* ============================================================
Wagon performance report — hover affordances.
Only the states Mantine props cannot express live here. Everything
structural stays in the components; this file is purely "what changes
under the pointer".
============================================================ */
/* Leaderboard rows are buttons that open a wagon — they need to say so. */
.wp-rank-row {
border-radius: 8px;
transition:
background-color 120ms ease,
transform 120ms ease;
}
.wp-rank-row:hover {
background: var(--mantine-color-edr-slate-soft-0);
}
.wp-rank-row:active {
transform: scale(0.995);
}
.wp-rank-row:focus-visible {
outline: 2px solid var(--mantine-color-edr-green-5);
outline-offset: -2px;
}
/* Cards lift very slightly on hover — enough to read as a surface, not
enough to make a still page feel restless. */
.wp-stat-tile {
transition:
box-shadow 140ms ease,
border-color 140ms ease;
}
.wp-stat-tile:hover {
border-color: var(--mantine-color-edr-border-0);
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.07);
}
/* Bars dim their neighbours on hover so the hovered one reads as selected. */
.wp-col-chart:hover .wp-col-bar {
opacity: 0.45;
}
.wp-col-chart .wp-col-bar:hover,
.wp-col-chart:hover .wp-col-slot:hover .wp-col-bar {
opacity: 1;
}

View File

@@ -91,6 +91,7 @@ import type {
TrainScheduleFilters,
TrainScheduleListFilters,
TrainScheduleListResponse,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -713,6 +714,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
reduceScheduleCloseOffset: endpoint<
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
TrainScheduleDetail
>(
"train-scheduling",
"reduce-schedule-close-offset",
({ id, payload }) =>
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
@@ -3220,11 +3233,23 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
rescheduleOperation: endpoint<
{
id: string;
scheduledDate: string;
trainScheduleId?: string;
note?: string;
},
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
bookingsService.rescheduleOperation(id, payload),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string; trainScheduleId?: string },
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
),
generateContract: endpoint<{ id: string }, BookingDetail>(

View File

@@ -355,8 +355,21 @@ export const bookingsService = {
* customer path uses the same endpoint from the portal; GL needs it here
* because a customs booking is GL's to fix, not the customer's.
*/
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
proceedToOperation: (id: string, scheduledDate: string, trainScheduleId?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), {
scheduledDate,
...(trainScheduleId ? { trainScheduleId } : {}),
}),
/**
* Operations changes the shipment day (and, for export rail, the train) of a
* pending operation request on the customer's behalf — the booking stays at
* OPERATION_REQUEST_PENDING for the normal accept.
*/
rescheduleOperation: (
id: string,
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -303,6 +303,14 @@ export const contractsService = {
resume: (id: string, note?: string) =>
postContract<Freight.IContract>(C.RESUME(id), { note }),
/**
* Add validity days to an EXPIRED contract the customer asked to extend; it
* returns to the status it held before it lapsed. The API refuses it while
* no customer request is pending.
*/
extend: (id: string, payload: Freight.ExtendContractDto) =>
postContract<Freight.IContract>(C.EXTEND(id), payload),
/**
* Approve the next pending step. The server resolves the step's required role
* and authorizes against it — the client never declares its own role.

View File

@@ -0,0 +1,114 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export type TrainCrewRole =
| 'TRAIN_DRIVER'
| 'FEDERAL_POLICE'
| 'TECHNICIAN'
| 'REEFER_TECHNICIAN'
| 'HAZMAT_ESCORT'
| 'LASHING_INSPECTOR'
| 'LIVESTOCK_HANDLER';
export type TrainCrewNationality = 'ETHIOPIAN' | 'DJIBOUTIAN';
export type TrainCrewStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'ON_LEAVE';
export interface TrainCrewMember {
id: string;
firstName: string;
lastName: string;
role: TrainCrewRole;
nationality: TrainCrewNationality;
status: TrainCrewStatus;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TrainCrewListFilters {
search?: string;
role?: TrainCrewRole;
nationality?: TrainCrewNationality;
status?: TrainCrewStatus;
isActive?: boolean;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/** Paginated envelope returned by GET /train-crew. */
export interface TrainCrewListResponse {
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}
export type SaveTrainCrewMemberPayload = Omit<
TrainCrewMember,
'id' | 'createdAt' | 'updatedAt'
>;
export const trainCrewService = {
getAll: (filters: TrainCrewListFilters = {}) => {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.role) params.set('role', filters.role);
if (filters.nationality) params.set('nationality', filters.nationality);
if (filters.status) params.set('status', filters.status);
if (filters.isActive !== undefined) {
params.set('isActive', String(filters.isActive));
}
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.sortBy) params.set('sortBy', filters.sortBy);
if (filters.sortOrder) params.set('sortOrder', filters.sortOrder);
const qs = params.toString();
return apiClient.get<TrainCrewListResponse>(
`${URL_CONSTANTS.TRAIN_CREW.BASE}${qs ? `?${qs}` : ''}`,
);
},
getById: (id: string) =>
apiClient.get<TrainCrewMember>(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
create: (data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.post(URL_CONSTANTS.TRAIN_CREW.BASE, data),
update: (id: string, data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.patch(URL_CONSTANTS.TRAIN_CREW.BY_ID(id), data),
delete: (id: string) => apiClient.delete(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
};
export const TRAIN_CREW_ROLE_OPTIONS: Array<{ label: string; value: TrainCrewRole }> = [
{ label: 'Train Driver', value: 'TRAIN_DRIVER' },
{ label: 'Federal Police', value: 'FEDERAL_POLICE' },
{ label: 'Technician', value: 'TECHNICIAN' },
{ label: 'Reefer Technician', value: 'REEFER_TECHNICIAN' },
{ label: 'HAZMAT Escort', value: 'HAZMAT_ESCORT' },
{ label: 'Lashing Inspector', value: 'LASHING_INSPECTOR' },
{ label: 'Livestock Handler', value: 'LIVESTOCK_HANDLER' },
];
export const TRAIN_CREW_NATIONALITY_OPTIONS: Array<{
label: string;
value: TrainCrewNationality;
}> = [
{ label: 'Ethiopian', value: 'ETHIOPIAN' },
{ label: 'Djiboutian', value: 'DJIBOUTIAN' },
];
export const TRAIN_CREW_STATUS_OPTIONS: Array<{ label: string; value: TrainCrewStatus }> = [
{ label: 'Active', value: 'ACTIVE' },
{ label: 'Inactive', value: 'INACTIVE' },
{ label: 'Suspended', value: 'SUSPENDED' },
{ label: 'On leave', value: 'ON_LEAVE' },
];
export const trainCrewRoleLabel = (role: TrainCrewRole): string =>
TRAIN_CREW_ROLE_OPTIONS.find((o) => o.value === role)?.label ?? role;
export const trainCrewNationalityLabel = (n: TrainCrewNationality): string =>
TRAIN_CREW_NATIONALITY_OPTIONS.find((o) => o.value === n)?.label ?? n;
export const trainCrewStatusLabel = (s: TrainCrewStatus): string =>
TRAIN_CREW_STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s;

View File

@@ -0,0 +1,104 @@
import { api as apiClient } from '../auth/http';
import type { TrainCrewMember, TrainCrewRole } from './trainCrew.service';
export type CrewDutyRole = 'PRIMARY' | 'ASSISTANT' | 'BENCH_RELIEF';
/** A yard on the schedule's route, ordered along the corridor. */
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
export interface CrewAssignment {
id: string;
trainScheduleId: string;
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
fromYardId?: string | null;
toYardId?: string | null;
status: string;
crewMember?: TrainCrewMember;
}
/** What the consist and its cargo demand (§1.2), detected server-side. */
export interface CrewDemand {
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
export interface CrewRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint only; nothing enforces it. */
typical: number;
reason: string;
}
export interface CrewValidation {
complete: boolean;
issues: Array<{ code: string; message: string }>;
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
export interface ScheduleCrewResponse {
scheduleId: string;
assignments: CrewAssignment[];
/** Yards a driver leg may use — bounded by the schedule's own endpoints. */
corridorYards: CorridorYard[];
demand: CrewDemand;
requirements: {
technician: CrewRequirement;
specialized: CrewRequirement[];
};
validation: CrewValidation;
}
export interface SaveCrewPayload {
assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}>;
}
const base = (scheduleId: string) => `/train-schedules/${scheduleId}/crew`;
export const trainCrewAssignmentService = {
get: (scheduleId: string) =>
apiClient.get<ScheduleCrewResponse>(base(scheduleId)),
eligibleDrivers: (scheduleId: string, fromYardId: string, toYardId: string) =>
apiClient.get<TrainCrewMember[]>(
`${base(scheduleId)}/eligible-drivers?fromYardId=${fromYardId}&toYardId=${toYardId}`,
),
corridorYards: (scheduleId: string) =>
apiClient.get<CorridorYard[]>(`${base(scheduleId)}/corridor-yards`),
save: (scheduleId: string, payload: SaveCrewPayload) =>
apiClient.put<CrewValidation>(base(scheduleId), payload),
};
export const DUTY_ROLE_OPTIONS: Array<{ value: CrewDutyRole; label: string }> = [
{ value: 'PRIMARY', label: 'Primary Driver' },
{ value: 'ASSISTANT', label: 'Assistant Driver' },
{ value: 'BENCH_RELIEF', label: 'Bench/Relief Driver' },
];
export const dutyRoleLabel = (dutyRole: CrewDutyRole): string =>
({
PRIMARY: 'Primary Driver',
ASSISTANT: 'Assistant Driver',
BENCH_RELIEF: 'Bench/Relief Driver',
})[dutyRole];

View File

@@ -36,6 +36,7 @@ import type {
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -307,6 +308,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
reduceScheduleCloseOffset: async (
scheduleId: string,
payload: ReduceScheduleCloseOffsetPayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CLOSE_OFFSET(scheduleId),
payload,
);
return unwrap(response.data);
},
updateScheduleDate: async (
scheduleId: string,
scheduleDate: string,

View File

@@ -250,6 +250,9 @@ export interface TrainScheduleListItem {
totalLengthMeters: number;
bookingsCount: number;
status: TrainScheduleStatus | string;
windowPhase?: BookingWindowPhase | string | null;
/** Set when the API computed it: is this row shut only by its close offset? */
closeOffsetReopen?: CloseOffsetReopenInfo | null;
}
export type TrainScheduleSortField =
@@ -593,11 +596,35 @@ export interface ScheduleWindowRule {
windowDurationHours: number | null;
importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Effective minutes-before-departure booking closes (import); null = at departure. */
importCloseOffsetMinutes?: number | null;
/** Effective minutes-before-departure booking closes (export); null = at departure. */
exportCloseOffsetMinutes?: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
}
/**
* Whether a schedule's booking shut ONLY because of its close offset — the one
* closed state staff can undo from the board by shortening that offset.
*/
export interface CloseOffsetReopenInfo {
eligible: boolean;
/** Why it is not eligible; null when it is. */
reason: string | null;
/** Minutes before departure booking currently closes; null without an offset. */
offsetMinutes: number | null;
/** ISO instant booking closed at (departure offset); null without an offset. */
cutoffAt: string | null;
}
/** Shorten a schedule's close offset so its booking window reopens. */
export interface ReduceScheduleCloseOffsetPayload {
/** New minutes-before-departure booking closes; 0 = close at departure. */
closeOffsetMinutes: number;
}
/** Editable window-rule override for one schedule; every field optional. */
export interface UpdateScheduleWindowRulePayload {
windowOpenHour?: number;
@@ -672,6 +699,8 @@ export interface TrainScheduleDetail {
paymentPhaseEndsAt?: string | null;
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
windowRule?: ScheduleWindowRule | null;
/** Is booking shut only by the close offset? Drives "shorten close offset". */
closeOffsetReopen?: CloseOffsetReopenInfo | null;
route?: {
id: string;
name: string;