mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
Merge pull request #701 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
Reference in New Issue
Block a user