mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
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:
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal file
340
apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user