mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +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:
@@ -142,6 +142,21 @@ export function BookingSchedulingWindowCard({
|
||||
schedule?.reference ??
|
||||
(schedule ? "Assigned train" : null);
|
||||
|
||||
// Before the batch engine allocates, the only train on the booking is the one
|
||||
// the customer picked at day-commit — staff review that during Operation
|
||||
// Review, so it is labelled as a request, not as a confirmed allocation.
|
||||
const isRequested = schedule?.isRequested === true;
|
||||
const trainRowLabel = isRequested ? "Requested train" : "Scheduled on train";
|
||||
|
||||
// A train that has already left (or is past its planned departure) can no
|
||||
// longer carry this booking, so accepting onto it would be wrong. Called out
|
||||
// here because this card sits above the staff-actions toolbar.
|
||||
const departureIso =
|
||||
schedule?.actualDepartureAt ?? schedule?.scheduledDepartureDate ?? null;
|
||||
const hasDeparted = schedule?.actualDepartureAt
|
||||
? true
|
||||
: departureIso !== null && new Date(departureIso).getTime() <= nowMs;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={CalendarClock}
|
||||
@@ -153,7 +168,7 @@ export function BookingSchedulingWindowCard({
|
||||
<Stack gap="sm">
|
||||
{trainLabel ? (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
label={trainRowLabel}
|
||||
value={trainLabel}
|
||||
hint={
|
||||
schedule?.reference && schedule.reference !== trainLabel
|
||||
@@ -163,13 +178,21 @@ export function BookingSchedulingWindowCard({
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
label={trainRowLabel}
|
||||
value="Not yet allocated"
|
||||
tone="muted"
|
||||
hint="The booking has not been placed on a train schedule"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRequested && trainLabel ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{hasDeparted
|
||||
? "This train has already departed — accepting the operation will not place the booking on it."
|
||||
: "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{schedule?.status ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -231,10 +254,15 @@ export function BookingSchedulingWindowCard({
|
||||
formatStamp(schedule.scheduledDepartureDate) ??
|
||||
"—"
|
||||
}
|
||||
tone={isRequested && hasDeparted ? "danger" : undefined}
|
||||
hint={
|
||||
schedule.actualDepartureAt
|
||||
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
|
||||
: "Planned"
|
||||
: departureIso && !hasDeparted
|
||||
? `Planned · departs ${formatRelative(departureIso, nowMs)}`
|
||||
: hasDeparted
|
||||
? "Planned — already past"
|
||||
: "Planned"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
|
||||
@@ -1006,7 +1006,7 @@ export function ReleaseOrderCard({
|
||||
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
|
||||
);
|
||||
@@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({
|
||||
Release Order
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
|
||||
<FileInput
|
||||
label="Release Order"
|
||||
placeholder="Select one or more files"
|
||||
multiple
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
size="sm"
|
||||
/>
|
||||
<DateInput
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
@@ -1032,15 +1039,15 @@ export function ReleaseOrderCard({
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file || !vesselDate}
|
||||
disabled={files.length === 0 || !vesselDate}
|
||||
onClick={async () => {
|
||||
if (!file || !vesselDate) return;
|
||||
if (files.length === 0 || !vesselDate) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const iso = vesselDate.toISOString().slice(0, 10);
|
||||
const result = isBooking
|
||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
||||
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, files, iso);
|
||||
if (result.hold) {
|
||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||
} else {
|
||||
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
toIsoDate,
|
||||
useDoCollectionDates,
|
||||
} from "@/components/contracts/DoCollectionDateFields";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types";
|
||||
|
||||
export type GlClearanceUploadKind = "do" | "ro";
|
||||
|
||||
@@ -44,9 +43,8 @@ export function GlClearanceUploadModal({
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
onSuccess,
|
||||
onPreview,
|
||||
}: GlClearanceUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||
);
|
||||
@@ -66,16 +64,16 @@ export function GlClearanceUploadModal({
|
||||
const isDo = kind === "do";
|
||||
const isRo = kind === "ro";
|
||||
const replaceMode = isDo
|
||||
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
|
||||
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file)
|
||||
: workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
setFiles([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || !kind) return;
|
||||
if (files.length === 0 || !kind) return;
|
||||
if (isRo && !vesselDate) {
|
||||
toast.error("Vessel departure date is required.");
|
||||
return;
|
||||
@@ -93,23 +91,23 @@ export function GlClearanceUploadModal({
|
||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||
};
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
await contractsService.uploadDeliveryOrder(entityId, files, dates);
|
||||
}
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
} else {
|
||||
const iso = vesselDate!.toISOString().slice(0, 10);
|
||||
const result = isBooking
|
||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
||||
? await bookingsService.uploadReleaseOrder(entityId, files, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, files, iso);
|
||||
if (result.hold) {
|
||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||
} else {
|
||||
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
||||
}
|
||||
}
|
||||
setFile(null);
|
||||
setFiles([]);
|
||||
onSuccess?.();
|
||||
close();
|
||||
} catch (e) {
|
||||
@@ -152,14 +150,15 @@ export function GlClearanceUploadModal({
|
||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||
)}
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||
description={isDo ? "Any file type." : "PDF or image."}
|
||||
<PhasedMultiFileDropzone
|
||||
label={isDo ? "Delivery Order files" : "Release Order files"}
|
||||
description={
|
||||
isDo ? "Any file type. Add as many files as needed." : "PDF or image. Add as many files as needed."
|
||||
}
|
||||
accept={isDo ? "*/*" : undefined}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
replaceMode={replaceMode}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
@@ -170,7 +169,7 @@ export function GlClearanceUploadModal({
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={
|
||||
!file ||
|
||||
files.length === 0 ||
|
||||
(isRo && !vesselDate) ||
|
||||
(isDo && !doDatesComplete(doDates))
|
||||
}
|
||||
|
||||
@@ -34,12 +34,15 @@ import {
|
||||
Truck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
deliveryOrderFileLabel,
|
||||
isDeliveryOrderFileCode,
|
||||
type Freight,
|
||||
} from "@edr/types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
|
||||
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
|
||||
import {
|
||||
DoCollectionDateFields,
|
||||
doDatesComplete,
|
||||
@@ -2133,56 +2136,82 @@ function DeliveryOrderStep({
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<Record<string, File | null>>({
|
||||
delivery_order: null,
|
||||
});
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [doDates, setDoDates] = useDoCollectionDates({
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasFile = Boolean(files.delivery_order);
|
||||
|
||||
const submit = async () => {
|
||||
if (files.length === 0 || !doDatesComplete(doDates)) return;
|
||||
const dates = {
|
||||
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, files, dates);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, files, dates);
|
||||
}
|
||||
setFiles([]);
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PhasedDocumentUploadField
|
||||
fields={[{ key: "delivery_order", label: "Delivery Order" }]}
|
||||
files={files}
|
||||
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))}
|
||||
workflowFiles={workflowFiles}
|
||||
replaceMode={replaceMode}
|
||||
loading={loading}
|
||||
disabled={!hasFile || !doDatesComplete(doDates)}
|
||||
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
|
||||
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
|
||||
extraFields={
|
||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onSubmit={async () => {
|
||||
const file = files.delivery_order;
|
||||
if (!file || !doDatesComplete(doDates)) return;
|
||||
const dates = {
|
||||
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
|
||||
doCollectedDate: toIsoDate(doDates.doCollected)!,
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file, dates);
|
||||
}
|
||||
setFiles({ delivery_order: null });
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the
|
||||
DO was collected. Add as many files as needed.
|
||||
</Text>
|
||||
|
||||
{workflowFiles
|
||||
.filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file)
|
||||
.map((wf, index) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={wf.code}
|
||||
label={deliveryOrderFileLabel(wf.code, index)}
|
||||
file={wf.file!}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
))}
|
||||
|
||||
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
|
||||
|
||||
<PhasedMultiFileDropzone
|
||||
label="Delivery Order files"
|
||||
description={
|
||||
replaceMode
|
||||
? "Replace the DO — upload one or more files (any file type)."
|
||||
: "Upload one or more Delivery Order files (any file type)."
|
||||
}
|
||||
}}
|
||||
/>
|
||||
accept="*/*"
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
replaceMode={replaceMode}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={files.length === 0 || !doDatesComplete(doDates)}
|
||||
leftSection={<Upload size={16} />}
|
||||
fullWidth
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{replaceMode ? "Replace DO" : "Upload DO"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FileSignature,
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
@@ -500,6 +501,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Audit logs",
|
||||
href: "/dashboard/audit-logs",
|
||||
icon: <History />,
|
||||
permission: FREIGHT_PERMS.auditLog.view,
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
const handleBuild = async () => {
|
||||
if (!trainName.trim()) {
|
||||
toast({
|
||||
title: "Enter the vogue number",
|
||||
title: "Enter the voyage number",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
wagons are attached on the next screen.
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Vogue number"
|
||||
placeholder="Enter vogue number"
|
||||
label="Voyage number"
|
||||
placeholder="Enter voyage number"
|
||||
value={trainName}
|
||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowRight, Landmark, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -50,9 +52,20 @@ function EligibleBookingRow({
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">
|
||||
{/* Opens the booking in a new tab: the row is a selection control in
|
||||
an allocation flow, so navigating away would lose staff's picks. */}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${booking.id}`}
|
||||
target="_blank"
|
||||
fw={600}
|
||||
size="sm"
|
||||
c="edr-green.8"
|
||||
underline="hover"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{resolvedFreightType ? (
|
||||
<Badge variant="outline" size="xs">
|
||||
{resolvedFreightType}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CircleAlert,
|
||||
Merge,
|
||||
Search,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export interface MergeScheduleTrainModalProps {
|
||||
scheduleId: string | null;
|
||||
/** This schedule's current train — excluded from the picker. */
|
||||
currentTrainId: string | null;
|
||||
scheduleReference?: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onMerged?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another train into this schedule.
|
||||
*
|
||||
* This schedule always survives: its train set is repointed at the chosen
|
||||
* train, that train's wagons join this consist, and the emptied train is
|
||||
* deactivated. When the chosen train also runs a schedule on the SAME DAY, that
|
||||
* schedule's bookings move here and it is removed — its other-day schedules
|
||||
* gain the wagons only. The server computes all of that in `previewMerge`, so
|
||||
* the summary below is exactly what the commit will perform.
|
||||
*/
|
||||
export default function MergeScheduleTrainModal({
|
||||
scheduleId,
|
||||
currentTrainId,
|
||||
scheduleReference,
|
||||
opened,
|
||||
onClose,
|
||||
onMerged,
|
||||
}: MergeScheduleTrainModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [selectedTrainId, setSelectedTrainId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
...api.trains.list.queryOptions(),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
// The schedule's own train cannot be merged into itself.
|
||||
const options = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return trains
|
||||
.filter((t) => t.id !== currentTrainId)
|
||||
.filter((t) =>
|
||||
q
|
||||
? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
: true,
|
||||
);
|
||||
}, [trains, currentTrainId, search]);
|
||||
|
||||
const { data: preview, isFetching: previewLoading } = useQuery({
|
||||
...api.trainScheduling.previewScheduleMerge.queryOptions({
|
||||
input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" },
|
||||
}),
|
||||
enabled: opened && Boolean(scheduleId && selectedTrainId),
|
||||
});
|
||||
|
||||
const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions());
|
||||
|
||||
const close = () => {
|
||||
setSelectedTrainId(null);
|
||||
setSearch("");
|
||||
setReason("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!scheduleId || !selectedTrainId || !preview?.canMerge) return;
|
||||
try {
|
||||
await merge.mutateAsync({
|
||||
id: scheduleId,
|
||||
targetTrainId: selectedTrainId,
|
||||
...(reason.trim() ? { reason: reason.trim() } : {}),
|
||||
});
|
||||
toast({ title: "Trains merged" });
|
||||
onMerged?.();
|
||||
close();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Merge failed",
|
||||
description: parseError(err, "Could not merge the trains"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Merge size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} lh={1.2}>
|
||||
Merge another train into this one
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{scheduleReference ?? "This departure survives the merge"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Search train code or number…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{trainsLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : options.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="sm">
|
||||
No other trains available to merge.
|
||||
</Text>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
|
||||
gap: 8,
|
||||
maxHeight: 190,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{options.map((t) => {
|
||||
const on = t.id === selectedTrainId;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="xs"
|
||||
onClick={() => setSelectedTrainId(t.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: on
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: undefined,
|
||||
background: on
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={on ? 700 : 600} truncate>
|
||||
{t.code}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t.trainNumber ? `No. ${t.trainNumber}` : "—"}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedTrainId && previewLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{selectedTrainId && preview && !previewLoading ? (
|
||||
<Stack gap="sm">
|
||||
{preview.blockers.length ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<CircleAlert size={16} />}
|
||||
title="This merge is blocked"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{preview.blockers.map((b) => (
|
||||
<Text size="sm" key={b}>
|
||||
{b}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<TriangleAlert size={16} />}
|
||||
>
|
||||
This cannot be undone. Wagons are appended last — reorder them
|
||||
afterwards in the train builder.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group gap={8} wrap="nowrap" mb={8}>
|
||||
<Text size="sm" fw={700}>
|
||||
{preview.wagons.current} wagons
|
||||
</Text>
|
||||
<ArrowRight size={15} />
|
||||
<Text size="sm" fw={700} c="edr-green.8">
|
||||
{preview.wagons.merged} wagons
|
||||
</Text>
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
+{preview.wagons.incoming} from {preview.targetTrain.code}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{preview.absorbedSchedule ? (
|
||||
<Group gap={6} wrap="nowrap" mb={6}>
|
||||
<Badge size="sm" color="grape" variant="light" radius="sm">
|
||||
{fmtDate(preview.absorbedSchedule.scheduledDepartureDate)}
|
||||
</Badge>
|
||||
<Text size="sm">
|
||||
{preview.absorbedSchedule.reference ?? "Same-day schedule"} —{" "}
|
||||
<Text span fw={700}>
|
||||
{preview.absorbedSchedule.bookingsMoving} booking(s)
|
||||
</Text>{" "}
|
||||
move here, then it is removed
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{preview.affectedSchedules.map((s) => (
|
||||
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
|
||||
<Badge size="sm" color="blue" variant="light" radius="sm">
|
||||
{fmtDate(s.scheduledDepartureDate)}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{s.reference ?? s.id.slice(0, 8)} — gains the wagons, keeps
|
||||
its own bookings
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{preview.untouchedSchedules.map((s) => (
|
||||
<Group gap={6} wrap="nowrap" mb={4} key={s.id}>
|
||||
<Badge size="sm" color="gray" variant="light" radius="sm">
|
||||
{fmtDate(s.scheduledDepartureDate)}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{s.reference ?? s.id.slice(0, 8)} — {s.status.toLowerCase()},
|
||||
not affected
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{preview.sourceTrainWillDeactivate ? (
|
||||
<Group gap={6} mt={6} wrap="nowrap">
|
||||
<Ban size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red.7">
|
||||
This schedule's current train is emptied and deactivated.
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{preview.canMerge ? (
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why the trains are being merged (kept on the audit trail)"
|
||||
maxLength={500}
|
||||
autosize
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Merge size={16} />}
|
||||
loading={merge.isPending}
|
||||
disabled={!preview?.canMerge || previewLoading}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Merge trains
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
@@ -7,7 +8,16 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
FileText,
|
||||
Landmark,
|
||||
MapPin,
|
||||
Package,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||
|
||||
@@ -16,6 +26,8 @@ import { EligibleBookingsPanel } from "./EligibleBookingsPanel";
|
||||
export type AssignedBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
/** Who booked it — staff scan this list by customer, not by reference. */
|
||||
customer?: string | null;
|
||||
weightTons?: number;
|
||||
isGovernment?: boolean;
|
||||
wagonsRequired?: number | null;
|
||||
@@ -90,9 +102,19 @@ export function ScheduleBookingsStep({
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{/* Only the reference is the link — the row also carries a
|
||||
Remove button, so an interactive control must not nest
|
||||
inside the anchor. */}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${booking.id}`}
|
||||
fw={600}
|
||||
size="sm"
|
||||
c="edr-green.8"
|
||||
underline="hover"
|
||||
>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Anchor>
|
||||
{booking.weightTons != null ? (
|
||||
<Badge variant="outline" size="xs" color="edr-green">
|
||||
{booking.weightTons}T
|
||||
@@ -119,6 +141,14 @@ export function ScheduleBookingsStep({
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{booking.customer ? (
|
||||
<Group gap={4}>
|
||||
<Building2 size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.customer}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{booking.contractReference || booking.origin || booking.destination ? (
|
||||
<Group gap="xs">
|
||||
{booking.contractReference ? (
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Check, Search, Train, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonPickerProps {
|
||||
/** The wagons on offer — already filtered to what the yard can hand over. */
|
||||
wagons: Wagon[];
|
||||
selected: Set<string>;
|
||||
onChange: (next: Set<string>) => void;
|
||||
/** Hard ceiling on the selection; further picks are refused once reached. */
|
||||
max?: number;
|
||||
emptyMessage?: string;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-select over a list of wagons, used wherever staff name the
|
||||
* physical wagons rather than a count. Search matches the wagon number as typed
|
||||
* (case-insensitively, ignoring the dashes and spaces operators leave out) so
|
||||
* "1042" finds "GON-1042".
|
||||
*/
|
||||
export function WagonPicker({
|
||||
wagons,
|
||||
selected,
|
||||
onChange,
|
||||
max,
|
||||
emptyMessage = "No wagons available here right now.",
|
||||
maxHeight = 260,
|
||||
}: WagonPickerProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const normalise = (s: string) => s.toLowerCase().replace(/[\s-]/g, "");
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = normalise(query.trim());
|
||||
if (!q) return wagons;
|
||||
return wagons.filter((w) => normalise(w.wagonNumber).includes(q));
|
||||
}, [wagons, query]);
|
||||
|
||||
const ceiling = max ?? Number.MAX_SAFE_INTEGER;
|
||||
const full = selected.size >= ceiling;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size >= ceiling) return; // at the cap — ignore the click
|
||||
else next.add(id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
/** Select as many of the currently-visible wagons as the cap allows. */
|
||||
const selectShown = () => {
|
||||
const next = new Set(selected);
|
||||
for (const w of shown) {
|
||||
if (next.size >= ceiling) break;
|
||||
next.add(w.id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Search wagon number…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<UnstyledButton onClick={() => setQuery("")} aria-label="Clear search">
|
||||
<X size={14} />
|
||||
</UnstyledButton>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
radius="md"
|
||||
onClick={selectShown}
|
||||
disabled={shown.length === 0 || full}
|
||||
>
|
||||
Select {query ? "matches" : "all"}
|
||||
</Button>
|
||||
{selected.size > 0 ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
onClick={() => onChange(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{shown.length} of {wagons.length} shown
|
||||
</Text>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={full ? "orange" : selected.size > 0 ? "teal" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{selected.size} selected{max != null ? ` / ${max}` : ""}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="sm">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : shown.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="sm">
|
||||
No wagon matches “{query}”.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={maxHeight} type="auto">
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
|
||||
gap: 8,
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{shown.map((w) => {
|
||||
const on = selected.has(w.id);
|
||||
// At the cap, unpicked wagons stop responding — grey them out so
|
||||
// the dead click is explained before it happens.
|
||||
const blocked = !on && full;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={w.id}
|
||||
onClick={() => toggle(w.id)}
|
||||
disabled={blocked}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid var(--mantine-color-${
|
||||
on ? "edr-green-5" : "gray-3"
|
||||
})`,
|
||||
background: on
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-body)",
|
||||
opacity: blocked ? 0.45 : 1,
|
||||
cursor: blocked ? "not-allowed" : "pointer",
|
||||
transition: "border-color 120ms, background 120ms",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onChange={() => toggle(w.id)}
|
||||
disabled={blocked}
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
tabIndex={-1}
|
||||
styles={{ input: { cursor: blocked ? "not-allowed" : "pointer" } }}
|
||||
/>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={on ? "edr-green" : "gray"}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
>
|
||||
{on ? <Check size={12} /> : <Train size={12} />}
|
||||
</ThemeIcon>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={on ? 700 : 500}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
truncate
|
||||
>
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default WagonPicker;
|
||||
@@ -26,6 +26,8 @@ import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Wagon } from "@/services/wagon.service";
|
||||
|
||||
import WagonPicker from "./WagonPicker";
|
||||
|
||||
const stripHtml = (html: string) => html.replace(/<[^>]*>/g, "").trim();
|
||||
|
||||
export interface WagonYardWorkspaceModalProps {
|
||||
@@ -140,6 +142,8 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||
const [transferQty, setTransferQty] = useState(0);
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
/** Specific wagons the requester named — optional; empty means "any N". */
|
||||
const [pickedWagons, setPickedWagons] = useState<Set<string>>(new Set());
|
||||
|
||||
const createRequest = useMutation(
|
||||
api.wagonTransferRequests.create.mutationOptions(),
|
||||
@@ -240,8 +244,25 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
setTransferYardId(null);
|
||||
setTransferQty(0);
|
||||
setTransferReason("");
|
||||
setPickedWagons(new Set());
|
||||
}, [yardId, typeId]);
|
||||
|
||||
// Naming wagons IS the ask: the count follows the picks so the two can never
|
||||
// disagree. Lowering the count by hand (below) trims the selection instead.
|
||||
const handlePick = (next: Set<string>) => {
|
||||
setPickedWagons(next);
|
||||
if (next.size > 0) setTransferQty(next.size);
|
||||
};
|
||||
|
||||
const handleQtyChange = (n: number) => {
|
||||
setTransferQty(n);
|
||||
// Asking for fewer than were picked would send a selection the API rejects
|
||||
// (picks may not exceed quantity) — drop the extras, keeping pick order.
|
||||
if (pickedWagons.size > n) {
|
||||
setPickedWagons(new Set([...pickedWagons].slice(0, n)));
|
||||
}
|
||||
};
|
||||
|
||||
// Reset the whole workspace when closed.
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -274,16 +295,23 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
wagonTypeId: typeId,
|
||||
quantity: transferQty,
|
||||
reason: transferReason,
|
||||
...(pickedWagons.size > 0
|
||||
? { preferredWagonIds: [...pickedWagons] }
|
||||
: {}),
|
||||
});
|
||||
toast({
|
||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||
yardId,
|
||||
)} → ${yardName(transferYardId)}`,
|
||||
description: "OCC will pick the wagons and complete the move.",
|
||||
description:
|
||||
pickedWagons.size > 0
|
||||
? `OCC will send the ${pickedWagons.size} wagon(s) you named where it can.`
|
||||
: "OCC will pick the wagons and complete the move.",
|
||||
});
|
||||
setTransferQty(0);
|
||||
setTransferYardId(null);
|
||||
setTransferReason("");
|
||||
setPickedWagons(new Set());
|
||||
} catch (err) {
|
||||
showError(err, "Request failed");
|
||||
}
|
||||
@@ -438,10 +466,36 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
ask for more than the yard can hand over. */}
|
||||
<QuantityField
|
||||
value={transferQty}
|
||||
onChange={setTransferQty}
|
||||
onChange={handleQtyChange}
|
||||
max={availableCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Optional: name the exact wagons. Leaving this empty files
|
||||
a plain count request and OCC picks whatever is free. */}
|
||||
<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">
|
||||
{pickedWagons.size > 0
|
||||
? `${pickedWagons.size} named — OCC will prioritise these`
|
||||
: "Leave empty and OCC picks any available"}
|
||||
</Text>
|
||||
</Group>
|
||||
<WagonPicker
|
||||
wagons={availableWagons}
|
||||
selected={pickedWagons}
|
||||
onChange={handlePick}
|
||||
max={availableCount}
|
||||
emptyMessage="No available wagons of this type in this yard right now."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder="Select destination"
|
||||
@@ -464,23 +518,42 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
</div>
|
||||
{transferYardId && transferQty > 0 ? (
|
||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(yardId!)} {total}
|
||||
<Text span c="red.6" fw={700}>
|
||||
{" "}
|
||||
−{transferQty}
|
||||
<Stack gap={6}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(yardId!)} {total}
|
||||
<Text span c="red.6" fw={700}>
|
||||
{" "}
|
||||
−{transferQty}
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
<ArrowRight size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(transferYardId)}
|
||||
<Text span c="teal.7" fw={700}>
|
||||
{" "}
|
||||
+{transferQty}
|
||||
<ArrowRight size={16} />
|
||||
<Text size="sm" fw={600}>
|
||||
{yardName(transferYardId)}
|
||||
<Text span c="teal.7" fw={700}>
|
||||
{" "}
|
||||
+{transferQty}
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{pickedWagons.size > 0 ? (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{availableWagons
|
||||
.filter((w) => pickedWagons.has(w.id))
|
||||
.map((w) => (
|
||||
<Badge
|
||||
key={w.id}
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
>
|
||||
{w.wagonNumber}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user