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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-18 16:19:34 +03:00
committed by GitHub
53 changed files with 4019 additions and 218 deletions

View File

@@ -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&apos;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}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>