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

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

View File

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

View File

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