mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge pull request #1337 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Milestone,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -47,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
|
||||
import {
|
||||
detailStyles,
|
||||
BookingRouteServiceCard,
|
||||
@@ -79,14 +82,50 @@ export default function BookingRequestDetailPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
|
||||
useScrollToHash();
|
||||
|
||||
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
|
||||
// other half of the shared wagon. Everything below — KPIs, stepper, the
|
||||
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
|
||||
// from the selected booking, so each half gets its own complete detail page
|
||||
// under a top-level tab. The URL id stays put so Back still works.
|
||||
const selectedId = searchParams.get("booking") || id;
|
||||
const {
|
||||
data: booking,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
} = useBookingDetail(selectedId);
|
||||
const mutations = useBookingMutations(selectedId ?? "");
|
||||
|
||||
// The pair is discovered from whichever half is on screen: each booking
|
||||
// carries a reference to the other.
|
||||
const routeBookingId = id ?? "";
|
||||
const partnerId = booking?.consolidationPartnerId ?? null;
|
||||
const isPaired = Boolean(partnerId);
|
||||
const viewingPartner = selectedId !== routeBookingId;
|
||||
// Tab identities: the booking named by the URL is always the first tab, the
|
||||
// other half the second — regardless of which one is currently displayed.
|
||||
const firstTabId = routeBookingId;
|
||||
const secondTabId = viewingPartner ? selectedId : partnerId;
|
||||
|
||||
// Only for the tab label (reference + customer) — the displayed half is
|
||||
// loaded above. Skipped entirely when the booking is not part of a pair.
|
||||
const { data: otherBooking } = useBookingDetail(
|
||||
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
|
||||
);
|
||||
const firstTabBooking = viewingPartner ? otherBooking : booking;
|
||||
const secondTabBooking = viewingPartner ? booking : otherBooking;
|
||||
|
||||
const selectBooking = (bookingId: string) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (bookingId === routeBookingId) next.delete("booking");
|
||||
else next.set("booking", bookingId);
|
||||
// Switching booking resets the sub-tab: the other half has its own content
|
||||
// and may not even have the tab that was open (e.g. Orders).
|
||||
next.delete("tab");
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -349,6 +388,48 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
|
||||
below. The overview/orders/documents/trucks tabs further down are
|
||||
sub-tabs of whichever booking is selected here. */}
|
||||
{isPaired && secondTabId ? (
|
||||
<Tabs
|
||||
value={selectedId ?? undefined}
|
||||
onChange={(value) => value && selectBooking(value)}
|
||||
variant="pills"
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
|
||||
<Stack gap={0} align="flex-start">
|
||||
<Text fz={13} fw={700}>
|
||||
{firstTabBooking?.reference ?? "Booking"}
|
||||
</Text>
|
||||
<Text fz={11} c="dimmed">
|
||||
{firstTabBooking?.company?.name ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
|
||||
<Stack gap={0} align="flex-start">
|
||||
<Text fz={13} fw={700}>
|
||||
{secondTabBooking?.reference ?? "Partner booking"}
|
||||
</Text>
|
||||
<Text fz={11} c="dimmed">
|
||||
{secondTabBooking?.company?.name ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{isPaired ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
These two bookings share one wagon. Accepting or cancelling applies
|
||||
to both; each is invoiced and paid separately.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<KpiStrip items={kpis} />
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
@@ -381,6 +462,21 @@ export default function BookingRequestDetailPage() {
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
|
||||
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<Link2 size={18} />}
|
||||
title="Waiting for shared-wagon approval"
|
||||
>
|
||||
<Text size="sm">
|
||||
This booking shares a wagon with another customer's booking.
|
||||
Both are held here until the pairing is approved — neither reaches
|
||||
Operations before then.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
@@ -455,6 +551,8 @@ export default function BookingRequestDetailPage() {
|
||||
that train and its clock must be readable before the approve
|
||||
button. */}
|
||||
<BookingSchedulingWindowCard booking={booking} />
|
||||
{/* Renders itself only when this booking has a shared wagon. */}
|
||||
<ConsolidationApprovalCard bookingId={booking.id} />
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LayoutList,
|
||||
Link2,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -210,10 +211,28 @@ export default function BookingRequestsPage() {
|
||||
|
||||
// Search is applied server-side (via the `search` filter param) — no
|
||||
// client-side filtering here.
|
||||
const rows = useMemo(
|
||||
() => (data?.items ?? []).map(toBookingListRow),
|
||||
[data?.items],
|
||||
);
|
||||
const rows = useMemo(() => {
|
||||
const mapped = (data?.items ?? []).map(toBookingListRow);
|
||||
// Consolidated pairs share one wagon and are decided together, so they show
|
||||
// as ONE row. Keep the half that appears first in the current sort and hang
|
||||
// the other on it as `pairedWith`; the row renders both bookings' details
|
||||
// and opens the detail page, where each half gets its own tab.
|
||||
const byId = new Map(mapped.map((row) => [row.id, row]));
|
||||
const absorbed = new Set<string>();
|
||||
const merged: BookingListRow[] = [];
|
||||
for (const row of mapped) {
|
||||
if (absorbed.has(row.id)) continue;
|
||||
const partnerId = row.consolidationPartnerId;
|
||||
const partner = partnerId ? byId.get(partnerId) : undefined;
|
||||
if (partner && !absorbed.has(partner.id)) {
|
||||
absorbed.add(partner.id);
|
||||
merged.push({ ...row, pairedWith: partner });
|
||||
continue;
|
||||
}
|
||||
merged.push(row);
|
||||
}
|
||||
return merged;
|
||||
}, [data?.items]);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const hasSearch = controls.searchText.trim().length > 0;
|
||||
@@ -331,6 +350,22 @@ export default function BookingRequestsPage() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
{/* Shared wagon: the second booking rides in the same row, so the
|
||||
operator sees both customers before opening the pair. */}
|
||||
{b.pairedWith ? (
|
||||
<div className="mt-1.5 border-l-2 border-muted pl-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 shrink-0 opacity-70" />
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{b.pairedWith.reference}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{b.pairedWith.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
bookingsService,
|
||||
type ConsolidationApprovalRow,
|
||||
} from "@/services/bookings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight to Operations. A
|
||||
* consolidated one waits here: two customers' cargo rides one physical wagon
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [decision, setDecision] = useState<{
|
||||
row: ConsolidationApprovalRow;
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
decision?.kind === "approve"
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Could not record the decision")),
|
||||
});
|
||||
|
||||
// A rejection has to tell GL what to fix, so the reason is mandatory there.
|
||||
const confirmDisabled =
|
||||
decide.isPending || (decision?.kind === "reject" && !note.trim());
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Shared wagon approvals"
|
||||
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="sm">
|
||||
Awaiting approval
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={row.booking?.reference ?? row.bookingReference}
|
||||
company={row.booking?.company?.name}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(decision)}
|
||||
onClose={() => {
|
||||
if (!decide.isPending) close();
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
decision?.kind === "approve"
|
||||
? "Note (optional)"
|
||||
: "Reason (required)"
|
||||
}
|
||||
description={
|
||||
decision?.kind === "approve"
|
||||
? "Recorded with the approval for the audit trail."
|
||||
: "GL sees this on both bookings — say what has to change."
|
||||
}
|
||||
placeholder={
|
||||
decision?.kind === "approve"
|
||||
? "Anything worth recording…"
|
||||
: "e.g. the partner's cargo weights are unbalanced for one wagon"
|
||||
}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={close}
|
||||
disabled={decide.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={decision?.kind === "approve" ? "edr-green" : "red"}
|
||||
radius="md"
|
||||
loading={decide.isPending}
|
||||
disabled={confirmDisabled}
|
||||
onClick={() => decide.mutate()}
|
||||
>
|
||||
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
||||
function BookingSide({
|
||||
id,
|
||||
reference,
|
||||
company,
|
||||
}: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
company?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${id}`}
|
||||
fz={14}
|
||||
fw={700}
|
||||
c="blue.7"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{reference ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12.5} c="dimmed">
|
||||
{company ?? "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Weight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
@@ -98,7 +98,14 @@ export default function TrainBuilderDetailPage() {
|
||||
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
api.trainBuilder.composition.queryOptions({
|
||||
input: { id },
|
||||
enabled: Boolean(id),
|
||||
// Mutations seed this key from their own response (see `seedComposition`
|
||||
// in services/api.ts), so the cached consist is authoritative — the
|
||||
// global staleTime of 0 would otherwise refetch it on every remount.
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
@@ -111,6 +118,34 @@ export default function TrainBuilderDetailPage() {
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
// re-normalize + repaint every car for each keystroke or pending mutation.
|
||||
const diagramLocomotives = useMemo(
|
||||
() =>
|
||||
(composition?.locomotives ?? []).map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
})),
|
||||
[composition?.locomotives],
|
||||
);
|
||||
const diagramWagons = useMemo(
|
||||
() =>
|
||||
(composition?.wagons ?? []).map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
})),
|
||||
[composition?.wagons],
|
||||
);
|
||||
|
||||
// Staff identify a train by its operational run numbers, not the internal
|
||||
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
|
||||
const trainRunLabel =
|
||||
@@ -130,17 +165,62 @@ export default function TrainBuilderDetailPage() {
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
const withToast = useCallback(
|
||||
async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
// Stable handlers: the consist list and wagon picker are memoized, so a new
|
||||
// closure each render would defeat the memo and re-render every wagon row
|
||||
// (and re-mount the drag context) on unrelated state changes.
|
||||
// `trainId` is only absent before the composition loads, and these handlers
|
||||
// are wired to controls that render after that — the guard keeps the promise
|
||||
// rather than leaning on a non-null assertion.
|
||||
const trainId = composition?.id;
|
||||
const handleAssign = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not add wagons",
|
||||
);
|
||||
},
|
||||
[withToast, assignWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleReorder = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
);
|
||||
},
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
return (
|
||||
@@ -364,21 +444,8 @@ export default function TrainBuilderDetailPage() {
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
locomotives={diagramLocomotives}
|
||||
wagons={diagramWagons}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
@@ -412,12 +479,7 @@ export default function TrainBuilderDetailPage() {
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
onAssign={(wagonIds) =>
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not add wagons",
|
||||
)
|
||||
}
|
||||
onAssign={handleAssign}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -434,19 +496,9 @@ export default function TrainBuilderDetailPage() {
|
||||
wagons={composition.wagons}
|
||||
editable={composition.editable && canAssign}
|
||||
busy={busy}
|
||||
onReorder={(wagonIds) =>
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
)
|
||||
}
|
||||
onRemove={(wagonId) =>
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||
"Could not detach wagon",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||
onReorder={handleReorder}
|
||||
onRemove={handleRemove}
|
||||
onMaintenance={handleMaintenance}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user