mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
Merge remote-tracking branch 'origin/dev' into dj-franc
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, 0–100. */
|
||||
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, 0–100. */
|
||||
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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user