feat: add wagon usage computation and maintenance logging features

- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -0,0 +1,340 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Card,
Code,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import {
usePagination,
type OnChangeFn,
type PaginationState,
} from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import {
AUDIT_METHODS,
auditLogsService,
type AuditLog,
type AuditMethod,
} from "@/services/auditLogs.service";
/** Method → badge colour. Destructive actions read as the loudest. */
const METHOD_COLORS: Record<AuditMethod, string> = {
POST: "green",
PUT: "blue",
PATCH: "yellow",
DELETE: "red",
};
const OUTCOME_OPTIONS = [
{ value: "true", label: "Succeeded" },
{ value: "false", label: "Failed" },
];
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
const { pagination, setPagination } = usePagination({ pageIndex: 0, pageSize: 25 });
const query = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to
// paste one when tracing what happened to a specific contract/booking.
resourceId: search.trim() || undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, dateFrom, dateTo],
);
const logsQuery = useQuery({
queryKey: ["audit-logs", query],
queryFn: () => auditLogsService.list(query),
});
const typesQuery = useQuery({
queryKey: ["audit-logs", "types"],
queryFn: () => auditLogsService.types(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome,
);
const resetFilters = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
setType(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
/** Any filter change must return to page 1, or the view can land out of range. */
const onFilterChange = <T,>(setter: (value: T) => void) => (value: T) => {
setter(value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
};
// `OnChangeFn` may hand back either a new value or an updater, so both forms
// are resolved before storing.
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
setPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
};
// `PageContainer` gives the page the same horizontal inset and vertical
// rhythm as every other dashboard screen; `fluid` lifts the max-width cap
// because the log table is wide.
return (
<PageContainer fluid>
<PageHeader
title="Audit logs"
subtitle="Every state-changing action taken by backoffice staff. Read-only — entries cannot be edited or removed."
/>
<Card withBorder padding="md">
<Stack gap="md">
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
onDateToChange={onFilterChange(setDateTo)}
dateLabel="Action date"
hasFilters={hasFilters}
onReset={resetFilters}
>
<Select
label="Entity"
placeholder="All entities"
data={typesQuery.data ?? []}
value={type}
onChange={onFilterChange(setType)}
clearable
searchable
w={200}
/>
<Select
label="Method"
placeholder="All methods"
data={[...AUDIT_METHODS]}
value={method}
onChange={onFilterChange(setMethod)}
clearable
w={150}
/>
<Select
label="Outcome"
placeholder="Any outcome"
data={OUTCOME_OPTIONS}
value={outcome}
onChange={onFilterChange(setOutcome)}
clearable
w={160}
/>
</ListControls>
{logsQuery.isLoading ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : logsQuery.isError ? (
<Text c="red" ta="center" py="xl">
Could not load audit logs.
</Text>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No audit entries match these filters.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((log) => (
<Table.Tr
key={log.id}
onClick={() => setSelected(log)}
style={{ cursor: "pointer" }}
>
<Table.Td maw={340}>
<Text size="sm" lineClamp={2}>
{log.title}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{log.userName ?? "—"}</Text>
{log.userRole ? (
<Text size="xs" c="dimmed">
{log.userRole}
</Text>
) : null}
</Table.Td>
<Table.Td>
{log.isSuccess ? (
<Badge color="green" variant="light">
Success
</Badge>
) : (
// The status code separates "denied" (403) from
// "broke" (500) — both are simply a failure here.
<Tooltip
label={log.errorMessage ?? "Failed"}
multiline
w={280}
disabled={!log.errorMessage}
>
<Badge color="red" variant="light">
Failed{log.statusCode ? ` · ${log.statusCode}` : ""}
</Badge>
</Tooltip>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
itemLabel="entries"
onPaginationChange={handlePaginationChange}
/>
</Stack>
</Card>
<Modal
opened={selected !== null}
onClose={() => setSelected(null)}
title="Audit entry"
size="lg"
>
{selected ? (
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />
<DetailRow label="Route" value={selected.routePath} />
<DetailRow
label="Outcome"
value={
selected.isSuccess
? `Success${selected.statusCode ? ` (${selected.statusCode})` : ""}`
: `Failed${selected.statusCode ? ` (${selected.statusCode})` : ""}`
}
/>
{selected.errorMessage ? (
<DetailRow label="Error" value={selected.errorMessage} />
) : null}
<DetailRow label="User" value={selected.userName} />
<DetailRow label="Role" value={selected.userRole} />
<DetailRow label="User id" value={selected.userId} />
<DetailRow label="IP address" value={selected.ipAddress} />
<DetailRow label="Request id" value={selected.requestId} />
<DetailRow
label="Duration"
value={selected.durationMs === null ? null : `${selected.durationMs} ms`}
/>
<DetailRow label="When" value={formatTimestamp(selected.createdAt)} />
<div>
<Text size="sm" fw={600} mb={4}>
Request payload
</Text>
{selected.request ? (
// Secrets are already redacted and uploads reduced to
// descriptors by the API before storage.
<Code block style={{ maxHeight: 320, overflow: "auto" }}>
{JSON.stringify(selected.request, null, 2)}
</Code>
) : (
<Text size="sm" c="dimmed">
No payload recorded.
</Text>
)}
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
};
const DetailRow = ({ label, value }: { label: string; value: string | null }) => (
<Group gap="xs" wrap="nowrap" align="flex-start">
<Text size="sm" fw={600} w={120} style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="sm" style={{ wordBreak: "break-all" }}>
{value ?? "—"}
</Text>
</Group>
);
export default AuditLogsPage;

View File

@@ -247,7 +247,6 @@ export default function BookingRequestDetailPage() {
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingSchedulingWindowCard booking={booking} />
<BookingPricingSummary booking={booking} />
<Box id="warehouse-payments">
<WarehouseInfoCard
@@ -257,6 +256,11 @@ export default function BookingRequestDetailPage() {
tradeDirection={booking.tradeDirection}
/>
</Box>
{/* Sits directly above the staff actions: reviewing an operation
request means approving the booking onto a specific train, so
that train and its clock must be readable before the approve
button. */}
<BookingSchedulingWindowCard booking={booking} />
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -10,6 +10,7 @@ import {
Progress,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
@@ -82,6 +83,13 @@ export default function TrainBuilderDetailPage() {
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
@@ -103,6 +111,19 @@ export default function TrainBuilderDetailPage() {
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// 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 =
[
composition?.exportTrainNumber?.trim()
? `export ${composition.exportTrainNumber.trim()}`
: null,
composition?.importTrainNumber?.trim()
? `import ${composition.importTrainNumber.trim()}`
: null,
]
.filter(Boolean)
.join(" / ") || composition?.code;
const busy =
assignWagons.isPending ||
removeWagon.isPending ||
@@ -463,7 +484,7 @@ export default function TrainBuilderDetailPage() {
<Modal
opened={Boolean(maintenanceTarget)}
onClose={() => setMaintenanceTarget(null)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
@@ -476,14 +497,22 @@ export default function TrainBuilderDetailPage() {
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{composition.code}
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train number in
the wagon's history.
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setMaintenanceTarget(null)}>
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
@@ -495,11 +524,12 @@ export default function TrainBuilderDetailPage() {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
setMaintenanceTarget(null);
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>

View File

@@ -23,6 +23,7 @@ import {
CalendarClock,
CheckCircle2,
Clock,
Merge,
Container as ContainerIcon,
Eye,
FileText,
@@ -54,6 +55,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -114,6 +116,7 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
@@ -400,7 +403,6 @@ export default function TrainScheduleV2DetailPage() {
const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
@@ -667,6 +669,7 @@ export default function TrainScheduleV2DetailPage() {
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
customer: b.customer,
weightTons: b.weightTons,
isGovernment: b.isGovernment,
wagonsRequired: b.wagonsRequired,
@@ -940,7 +943,7 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
Train No.
</Text>
<Text
ff="monospace"
@@ -952,6 +955,33 @@ export default function TrainScheduleV2DetailPage() {
</Text>
</Box>
) : null}
{schedule.voyageNumber ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
Voyage No.
</Text>
<Text
ff="monospace"
fw={800}
lh={1.1}
style={{ fontSize: 32, color: "#0f172a" }}
>
{schedule.voyageNumber}
</Text>
</Box>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
<Button
variant="light"
size="compact-sm"
leftSection={<Merge size={14} />}
onClick={() => setMergeModalOpen(true)}
>
Merge
</Button>
) : null}
{schedule.direction ? (
<Box>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
@@ -1369,6 +1399,15 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<MergeScheduleTrainModal
scheduleId={scheduleId ?? null}
currentTrainId={schedule.trainSet?.trainId ?? null}
scheduleReference={schedule.reference ?? null}
opened={mergeModalOpen}
onClose={() => setMergeModalOpen(false)}
onMerged={() => void detailQuery.refetch()}
/>
<SwitchGovernmentBookingModal
key={switchTarget?.id ?? "none"}
opened={Boolean(switchTarget)}

View File

@@ -134,6 +134,9 @@ export default function TrainScheduleV2ListPage() {
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
@@ -424,9 +427,7 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
{/* Wagon SLOTS this schedule's bookings occupy — not the coupled
consist. A built train shows 0 here until bookings are allocated. */}
<MetricChip value={row.original.wagonCount} label="wgn used" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
@@ -503,21 +504,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
color="red"
leftSection={<Ban size={15} />}
onClick={async () => {
try {
await cancel.mutateAsync({
id: schedule.id,
freightType: schedule.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
onClick={() => setCancelTarget(schedule)}
>
Cancel schedule
</Menu.Item>
@@ -969,10 +956,118 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
{/* Cancelling a schedule is destructive and cannot be undone, so it is
confirmed here rather than firing straight from the row menu. */}
<Modal
opened={cancelTarget != null}
onClose={() => setCancelTarget(null)}
title="Cancel this schedule?"
centered
radius="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
<Text span fw={600} c="dark">
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
</Text>{" "}
will be cancelled and removed from the active schedule board. This
cannot be undone.
</Text>
{cancelTarget?.bookingsCount ? (
<Text size="sm" c="red.7" fw={500}>
{cancelTarget.bookingsCount} booking
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
need to be moved to another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setCancelTarget(null)}>
Keep schedule
</Button>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
onClick={async () => {
if (!cancelTarget) return;
try {
await cancel.mutateAsync({
id: cancelTarget.id,
freightType: cancelTarget.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
setCancelTarget(null);
void schedulesQuery.refetch();
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel schedule
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1078,7 +1173,7 @@ function ScheduleCard({
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn used" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>

View File

@@ -2,19 +2,18 @@ import { Freight } from "@edr/types";
import {
Alert,
Button,
Checkbox,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, PackageCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -59,23 +58,21 @@ export function TransferFulfillModal({
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const canTake = useMemo(
() => Math.min(outstanding, wagons.length),
[outstanding, wagons.length],
);
/** The requester's picks that are still in this yard and still available. */
const preferredHere = useMemo(() => {
const asked = new Set(request?.preferredWagonIds ?? []);
return asked.size ? wagons.filter((w) => asked.has(w.id)) : [];
}, [request, wagons]);
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
// Never let staff pick more than is still owed — the API rejects it too.
else if (next.size >= outstanding) return prev;
else next.add(id);
return next;
});
const missingPreferred =
(request?.preferredWagonIds?.length ?? 0) - preferredHere.length;
const takeAllAvailable = () =>
setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id)));
// Open on what the requester asked for: OCC confirms rather than re-picks.
// Re-runs when the wagon list arrives, and is capped at what is still owed.
useEffect(() => {
if (!request) return;
setPicked(new Set(preferredHere.slice(0, outstanding).map((w) => w.id)));
}, [request, preferredHere, outstanding]);
const close = () => {
setPicked(new Set());
@@ -120,27 +117,16 @@ export function TransferFulfillModal({
>
{!request ? null : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="sm">
<Text span fw={700}>
{outstanding}
</Text>{" "}
wagon(s) still owed ·{" "}
<Text span fw={700}>
{wagons.length}
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
<Button
variant="light"
size="xs"
radius="md"
disabled={canTake === 0}
onClick={takeAllAvailable}
>
Select {canTake}
</Button>
</Group>
<Text size="sm">
<Text span fw={700}>
{outstanding}
</Text>{" "}
wagon(s) still owed ·{" "}
<Text span fw={700}>
{wagons.length}
</Text>{" "}
available in {yardLabel(request.fromYard)}
</Text>
{wagons.length < outstanding ? (
<Alert color="yellow" radius="md" icon={<AlertTriangle size={15} />}>
@@ -150,27 +136,39 @@ export function TransferFulfillModal({
</Alert>
) : null}
{preferredHere.length > 0 ? (
<Alert color="teal" radius="md" icon={<PackageCheck size={15} />}>
The requester named{" "}
<Text span fw={700}>
{preferredHere.length}
</Text>{" "}
specific wagon(s) pre-selected below. You can change the
selection freely; the request is a count, not a reservation.
</Alert>
) : null}
{missingPreferred > 0 ? (
<Alert color="orange" radius="md" icon={<AlertTriangle size={15} />}>
{missingPreferred} of the wagon(s) the requester named{" "}
{missingPreferred === 1 ? "is" : "are"} no longer available in this
yard. Pick replacements below.
</Alert>
) : null}
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : wagons.length === 0 ? (
<Text c="dimmed" size="sm" py="md">
No available wagons of this type in the source yard right now.
</Text>
) : (
<ScrollArea.Autosize mah={320}>
<Stack gap={4}>
{wagons.map((w) => (
<Checkbox
key={w.id}
checked={picked.has(w.id)}
onChange={() => toggle(w.id)}
label={w.wagonNumber}
/>
))}
</Stack>
</ScrollArea.Autosize>
<WagonPicker
wagons={wagons}
selected={picked}
onChange={setPicked}
// Never let staff pick more than is still owed — the API rejects it too.
max={outstanding}
emptyMessage="No available wagons of this type in the source yard right now."
maxHeight={300}
/>
)}
<Group justify="flex-end" gap="sm">

View File

@@ -15,6 +15,7 @@ import { AlertTriangle, Send, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import WagonPicker from "@/components/wagons/WagonPicker";
import { api } from "@/services/api";
import type { WagonTransferRequest } from "@/services/wagon.service";
@@ -45,9 +46,11 @@ function useTransferOptions(enabled: boolean) {
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
* asking for more than this, so the field must not let one be filed. Returns
* the wagons themselves so the form can also offer them for picking; `count` is
* null until both a yard and a type are chosen.
*/
function useAvailableCount(
function useAvailableWagons(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
@@ -56,14 +59,15 @@ function useAvailableCount(
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
if (!fromYardId || !wagonTypeId) return { wagons: [], count: null };
const inYard = wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
);
return { wagons: inYard, count: inYard.length };
}
export interface TransferRequestFormModalProps {
@@ -95,6 +99,8 @@ export function TransferRequestFormModal({
const [wagonTypeId, setWagonTypeId] = useState<string | null>(null);
const [quantity, setQuantity] = useState<number | string>(1);
const [reason, setReason] = useState("");
/** Specific wagons the requester named — optional; empty means "any N". */
const [picked, setPicked] = useState<Set<string>>(new Set());
// Re-seed on every open so a carry-over never leaks into the next request.
useEffect(() => {
@@ -104,11 +110,27 @@ export function TransferRequestFormModal({
setWagonTypeId(prefillFrom?.wagonTypeId ?? null);
setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1);
setReason(prefillFrom?.reason ?? "");
setPicked(new Set());
}, [opened, prefillFrom]);
const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
const { wagons: availableWagons, count: available } = useAvailableWagons(
opened,
fromYardId,
wagonTypeId,
);
// The picks belong to one yard+type pair; changing either invalidates them.
useEffect(() => {
setPicked(new Set());
}, [fromYardId, wagonTypeId]);
// Naming wagons IS the ask, so the count follows the picks.
const handlePick = (next: Set<string>) => {
setPicked(next);
if (next.size > 0) setQuantity(next.size);
};
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
@@ -118,6 +140,16 @@ export function TransferRequestFormModal({
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
// Asking for fewer than were picked would send a selection the API rejects
// (picks may not exceed quantity) — drop the extras, keeping pick order.
const handleQuantityChange = (value: number | string) => {
setQuantity(value);
const n = Number(value);
if (Number.isFinite(n) && picked.size > n) {
setPicked(new Set([...picked].slice(0, Math.max(0, n))));
}
};
const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid =
@@ -136,6 +168,7 @@ export function TransferRequestFormModal({
wagonTypeId: wagonTypeId!,
quantity: Number(quantity),
reason: reason.trim(),
...(picked.size > 0 ? { preferredWagonIds: [...picked] } : {}),
});
toast.success("Transfer request filed");
onClose();
@@ -203,7 +236,7 @@ export function TransferRequestFormModal({
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity}
onChange={setQuantity}
onChange={handleQuantityChange}
disabled={available === 0}
error={
available === 0
@@ -214,6 +247,34 @@ export function TransferRequestFormModal({
}
required
/>
{/* Optional: name the exact wagons. Leaving this empty files a plain
count request and OCC picks whatever is free. */}
{availableWagons.length > 0 ? (
<div>
<Group justify="space-between" mb={4} wrap="wrap" gap={4}>
<Text size="sm" fw={500}>
Which wagons{" "}
<Text span size="xs" c="dimmed" fw={400}>
(optional)
</Text>
</Text>
<Text size="xs" c="dimmed">
{picked.size > 0
? `${picked.size} named — OCC will prioritise these`
: "Leave empty and OCC picks any available"}
</Text>
</Group>
<WagonPicker
wagons={availableWagons}
selected={picked}
onChange={handlePick}
max={available ?? undefined}
maxHeight={200}
/>
</div>
) : null}
<Textarea
label="Reason"
placeholder="Why the wagons are needed"

View File

@@ -52,6 +52,7 @@ import {
TransferRequestFormModal,
} from "./TransferRequestModals";
import {
PreferredWagonChips,
TransferProgress,
TransferStatusBadge,
fmtDateTime,
@@ -195,6 +196,11 @@ export default function WagonTransfersPage() {
<Text size="sm">{wagonTypeLabel(row.original.wagonType)}</Text>
),
},
{
id: "wagons",
header: () => <span>Wagons requested</span>,
cell: ({ row }) => <PreferredWagonChips request={row.original} />,
},
{
id: "progress",
header: () => <span>Delivered</span>,
@@ -564,6 +570,17 @@ export default function WagonTransfersPage() {
{wagonTypeLabel(viewingReason.wagonType)} ·{" "}
{viewingReason.quantity} wagon(s)
</Text>
{viewingReason.preferredWagons?.length ? (
<div>
<Text size="xs" c="dimmed" mb={4}>
Wagons requested
</Text>
<PreferredWagonChips
request={viewingReason}
limit={viewingReason.preferredWagons.length}
/>
</div>
) : null}
<Box
className="text-sm [&_p]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5"
dangerouslySetInnerHTML={{

View File

@@ -3,6 +3,65 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core";
import type { WagonTransferRequest } from "@/services/wagon.service";
/** How many wagon chips fit a table cell before the rest are rolled up. */
const CHIP_LIMIT = 4;
/**
* The wagons the requester actually named, when they picked any. Rendered as
* chips so a glance down the column separates "send me these 3" from a plain
* count request — the two are fulfilled differently.
*/
export function PreferredWagonChips({
request,
limit = CHIP_LIMIT,
}: {
request: WagonTransferRequest;
limit?: number;
}) {
const wagons = request.preferredWagons ?? [];
if (wagons.length === 0) {
return (
<Tooltip label="No specific wagons named — OCC picks any available" withArrow>
<Text size="xs" c="dimmed">
Any {request.quantity}
</Text>
</Tooltip>
);
}
const shown = wagons.slice(0, limit);
const rest = wagons.length - shown.length;
return (
<Tooltip
withArrow
multiline
maw={280}
label={`Requested: ${wagons.map((w) => w.wagonNumber).join(", ")}`}
>
<Group gap={4} wrap="wrap" maw={220}>
{shown.map((w) => (
<Badge
key={w.id}
size="sm"
variant="light"
color="edr-green"
radius="sm"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{w.wagonNumber}
</Badge>
))}
{rest > 0 ? (
<Badge size="sm" variant="outline" color="gray" radius="sm">
+{rest}
</Badge>
) : null}
</Group>
</Tooltip>
);
}
/** Reason/note fields come from a rich-text editor and store HTML — this
* gives a plain-text preview for list/table contexts (full formatting is
* shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */