Merge pull request #701 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-15 13:32:37 +03:00
committed by GitHub
43 changed files with 1900 additions and 312 deletions

View File

@@ -17,7 +17,8 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { BookingWindowUiKind } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
@@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string {
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
* between refetches announces what comes next rather than the bare "Expired".
* The countdown for the window's UI state, mirroring the customer portal.
* Derived from the SAME state as the badge (`bookingWindowUiState`) so they
* can never contradict — a full train shows no ticking countdown.
* `expiredText` names the NEXT step so a deadline that lapses between
* refetches announces what comes next rather than the bare "Expired".
*/
const COUNTDOWN_TEXT: Partial<
Record<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" },
OPEN: { label: "Closes in", expiredText: "Review starting…" },
DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment ends in", expiredText: "Closing…" },
};
function phaseCountdown(
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Opens in",
deadline: w.windowOpensAt,
expiredText: "Opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Closes in",
deadline: w.windowClosesAt,
expiredText: "Review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Doc review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment ends in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Closing…",
}
: null;
default:
return null;
}
const state = bookingWindowUiState(w);
const text = COUNTDOWN_TEXT[state.kind];
if (!state.countdownTo || !text) return null;
return { ...text, deadline: state.countdownTo };
}
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
DOC_REVIEW: { label: "Doc review", color: "gray" },
PAYMENT: { label: "Payment", color: "gray" },
CLOSED: { label: "Closed", color: "gray" },
};
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const state = bookingWindowUiState(w);
const badge = KIND_BADGE[state.kind];
const open = state.isBookable;
const isImport = w.direction === "IMPORT";
return (
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
color={badge.color}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
{badge.label}
</Badge>
</Group>

View File

@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
if (wagon.wagonType) {
// e.g. "Flat wagon (NW5)" — name with its type code.
byId.set(
wagon.wagonType.id,
wagon.wagonType.code
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
: wagon.wagonType.name,
);
}
}
return [
{ value: "ALL", label: "All types" },
@@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({
);
};
const allSelected =
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
const someSelected = wagons.some((w) => selected.includes(w.id));
const toggleAll = (checked: boolean) => {
setSelected((prev) => {
if (checked) {
const ids = new Set(prev);
wagons.forEach((w) => ids.add(w.id));
return [...ids];
}
const visible = new Set(wagons.map((w) => w.id));
return prev.filter((id) => !visible.has(id));
});
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
/>
</Group>
{wagons.length ? (
<Checkbox
size="sm"
label={`Select all (${wagons.length})`}
checked={allSelected}
indeterminate={!allSelected && someSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
/>
) : null}
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{wagonsQuery.isLoading ? (

View File

@@ -26,14 +26,19 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
// Run-number parity carries the trade direction: odd = export, even = import.
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
* yard. Wagons are attached afterwards on the composition page.
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
* the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -56,7 +61,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => {
if (!opened) {
setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
@@ -65,16 +71,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
if (!yardId || locomotiveIds.length < 2) {
toast({
title: "Enter a train code, pick a yard, and couple at least two locomotives",
title: "Pick a yard and couple at least two locomotives",
variant: "destructive",
});
return;
}
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
toast({
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
variant: "destructive",
});
return;
}
try {
const composition = await build.mutateAsync({
code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
@@ -108,22 +122,42 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Stack gap="md">
<Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons
standing in that same yard. Wagons are attached on the next screen.
standing in that same yard. The train code is assigned automatically;
wagons are attached on the next screen.
</Text>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
<Group grow>
<TextInput
label="Train code"
placeholder="e.g. 81001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
maxLength={32}
label="Export train number"
description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001"
value={exportTrainNumber}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
exportTrainNumber && !isOddNumber(exportTrainNumber)
? "Must be numeric and odd"
: undefined
}
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
/>
</Group>
<Select

View File

@@ -7,7 +7,7 @@ import {
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react";
import { GripVertical, Trash2, Wrench } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
editable,
onReorder,
onRemove,
onMaintenance,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
editable={editable}
busy={busy}
onRemove={onRemove}
onMaintenance={onMaintenance}
/>
)}
</Draggable>
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
busy?: boolean;
}
@@ -106,6 +110,7 @@ function WagonRow({
editable,
busy,
onRemove,
onMaintenance,
}: {
wagon: TrainCompositionWagon;
index: number;
@@ -114,6 +119,7 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
}) {
return (
<PortalAwareRow snapshot={snapshot}>
@@ -153,17 +159,30 @@ function WagonRow({
</Text>
</Stack>
{editable ? (
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
<Group gap={4} wrap="nowrap">
<Tooltip label="Send to maintenance (detaches)" withArrow>
<ActionIcon
variant="subtle"
color="orange"
disabled={busy}
onClick={() => onMaintenance(wagon.id)}
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
>
<Wrench size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
) : null}
</Group>
</PortalAwareRow>

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */
@@ -23,3 +25,17 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
/** Row background tint for a train whose active schedule runs in `direction`. */
export const directionRowStyle = (
direction?: string | null,
): CSSProperties | undefined =>
direction === "IMPORT"
? { backgroundColor: "var(--mantine-color-blue-0)" }
: direction === "EXPORT"
? { backgroundColor: "var(--mantine-color-orange-0)" }
: undefined;

View File

@@ -152,6 +152,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(),
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
null,
);
const [poolTarget, setPoolTarget] = useState<string | null>(null);
const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({
input: {
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
[targets, schedule.id],
);
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
// for a wagon may board any of them, so staff pick whichever has wagons free.
const eatDayOf = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
const sameDayOptions = useMemo(() => {
const day = eatDayOf(schedule.scheduledDepartureDate);
return (targets ?? [])
.filter((s) => eatDayOf(s.scheduleDate) === day)
.map((s) => ({
value: s.id,
label: `${s.id === schedule.id ? "This train · " : ""}${
s.routeName ?? `${s.origin}${s.destination}`
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
// ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule);
const capacity = pullCapacity(schedule);
@@ -288,6 +313,36 @@ export function ScheduleWorkspacePanel({
);
};
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
const doPoolAssign = () => {
if (!poolAssign || !poolTarget) return;
const { id: bookingId, reference } = poolAssign;
moveSchedule
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
.then(() => {
toast({
title: `${reference} assigned`,
description: "Booking placed on the selected train with wagons pinned.",
});
setPoolAssign(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: `Could not assign ${reference}`,
description: apiErrorMessage(
error,
"The selected train has no free wagon of the required type.",
),
variant: "destructive",
}),
);
};
const doMove = () => {
if (!moveBookingId || !moveTarget) return;
moveSchedule
@@ -465,20 +520,41 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={
canManage ? (
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
<Group gap={6} wrap="nowrap" justify="flex-end">
<Tooltip label="Force-add to this train" withArrow>
<Button
size="compact-sm"
color="edr-green"
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
<Tooltip
label="Pick any train departing this day that has wagons free"
withArrow
>
Add
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<ArrowLeftRight size={13} />}
onClick={() => {
setPoolAssign({ id: b.id, reference: b.reference });
setPoolTarget(null);
}}
>
Add to
</Button>
</Tooltip>
</Group>
) : null
}
/>
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
onClose={() => setPoolAssign(null)}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
Assign {poolAssign?.reference ?? "booking"} to a train on this day
</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="xs" c="dimmed">
All open trains departing on this schedule&apos;s day. Pick one with
free wagons the booking is placed and its wagons pinned in one step.
</Text>
<Select
label="Target train (same day)"
placeholder="Select a departure"
data={sameDayOptions}
value={poolTarget}
onChange={setPoolTarget}
searchable
nothingFoundMessage="No open schedules depart on this day"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPoolAssign(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!poolTarget}
loading={moveSchedule.isPending || assignUnassigned.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doPoolAssign}
>
Assign to train
</Button>
</Group>
</Stack>
</Modal>
{/* Reassign modal */}
<Modal
opened={Boolean(moveBookingId)}
@@ -710,6 +832,7 @@ function BookingCard({
weightTons,
status,
loadingStatus,
waitingForWagon,
right,
}: {
reference: string;
@@ -717,6 +840,8 @@ function BookingCard({
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
right?: React.ReactNode;
}) {
return (
@@ -742,6 +867,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
withArrow
>
<Badge size="sm" radius="sm" variant="light" color="orange">
Waiting for wagon
</Badge>
</Tooltip>
) : null}
{loadingStatus ? (
<Badge
size="sm"