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

@@ -67,6 +67,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -596,6 +597,36 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
previewScheduleMerge: endpoint<
{ id: string; targetTrainId: string },
ScheduleMergePreview
>(
"train-scheduling",
"merge-preview",
({ id, targetTrainId }) =>
trainSchedulingService.previewScheduleMerge(id, targetTrainId),
({ id, targetTrainId }) => [
"train-scheduling",
"merge-preview",
id,
targetTrainId,
],
),
mergeScheduleTrain: endpoint<
{ id: string; targetTrainId: string; reason?: string },
TrainScheduleDetail
>(
"train-scheduling",
"merge-train",
({ id, ...payload }) =>
trainSchedulingService.mergeScheduleTrain(id, payload),
undefined,
// Wagons and bookings move between trains and schedules, so the wagon and
// train caches are stale too — not just the scheduling ones.
() => [...TRAIN_SCHEDULING_INVALIDATIONS, ["wagons"], ["trains"]],
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",
@@ -2040,11 +2071,14 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
sendWagonToMaintenance: endpoint<
{ id: string; wagonId: string; note?: string },
TrainComposition
>(
"train-builder",
"sendWagonToMaintenance",
({ id, wagonId }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
({ id, wagonId, note }) =>
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),

View File

@@ -0,0 +1,97 @@
import type { PaginatedResponse } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = URL_CONSTANTS.AUDIT_LOGS.BASE;
/** Methods the audit trail records. Reads are never audited. */
export const AUDIT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
export type AuditMethod = (typeof AUDIT_METHODS)[number];
/**
* One recorded backoffice action.
*
* Mirrors `AuditLog` in the freight API. `userName` / `userRole` are snapshots
* taken when the action happened, not live lookups — an old row keeps the name
* and role the actor had at the time.
*/
export interface AuditLog {
id: string;
/** Readable action, e.g. "Approve contract". */
title: string;
method: AuditMethod;
/** URL as called, query string included (secrets already redacted server-side). */
url: string;
/** Route template, e.g. `/api/contracts/:id/cancel`. */
routePath: string | null;
/** Entity the action touched, e.g. "Contract". */
type: string;
isSuccess: boolean;
statusCode: number | null;
errorMessage: string | null;
userId: string | null;
userName: string | null;
userRole: string | null;
resourceId: string | null;
/** Sanitized request body; files appear as `__file` descriptors. */
request: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
durationMs: number | null;
createdAt: string;
}
export interface AuditLogQuery {
page?: number;
pageSize?: number;
type?: string;
userId?: string;
method?: AuditMethod;
resourceId?: string;
/** Omit for "any outcome". */
isSuccess?: boolean;
/** Inclusive ISO 8601 bounds. */
from?: string;
to?: string;
}
/**
* Drop empty filters so the request carries only what the user actually set —
* an empty string would otherwise be sent and fail the API's validation.
*/
function toParams(query: AuditLogQuery): Record<string, string | number> {
const params: Record<string, string | number> = {};
if (query.page) params.page = query.page;
if (query.pageSize) params.pageSize = query.pageSize;
if (query.type) params.type = query.type;
if (query.userId) params.userId = query.userId;
if (query.method) params.method = query.method;
if (query.resourceId) params.resourceId = query.resourceId;
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
if (query.from) params.from = query.from;
if (query.to) params.to = query.to;
return params;
}
export const auditLogsService = {
/** Paginated audit history, newest first. */
list: async (query: AuditLogQuery = {}): Promise<PaginatedResponse<AuditLog>> => {
const response = await client.get<ApiResponse<PaginatedResponse<AuditLog>>>(
`${BASE}/logs`,
{ params: toParams(query) },
);
return unwrap(response.data);
},
/** Distinct entity types present, for the filter dropdown. */
types: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
return unwrap(response.data);
},
};

View File

@@ -420,11 +420,11 @@ export const bookingsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -435,11 +435,11 @@ export const bookingsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ hold?: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -468,11 +468,11 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
@@ -483,11 +483,11 @@ export const contractsService = {
uploadReleaseOrder: async (
id: string,
file: File,
files: File[],
vesselDepartureDate: string,
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
const form = new FormData();
form.append("file", file);
files.forEach((file) => form.append("files", file));
form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },

View File

@@ -309,8 +309,11 @@ export const trainBuilderService = {
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
/** Detach a wagon and move it to MAINTENANCE status. */
sendWagonToMaintenance: (id: string, wagonId: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
/** `note` is the maintenance reason — recorded with the train it came off. */
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -29,6 +29,7 @@ import type {
PinWagonsPayload,
RecordCheckpointPayload,
StaffBookingWindow,
ScheduleMergePreview,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
@@ -294,6 +295,29 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** What a merge would do — drives the confirmation modal. Read-only. */
previewScheduleMerge: async (
scheduleId: string,
targetTrainId: string,
): Promise<ScheduleMergePreview> => {
const response = await client.get<ScheduleMergePreview>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_PREVIEW(scheduleId, targetTrainId),
);
return unwrap(response.data);
},
/** Merge another train into this schedule. This schedule always survives. */
mergeScheduleTrain: async (
scheduleId: string,
payload: { targetTrainId: string; reason?: string },
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.MERGE_TRAIN(scheduleId),
payload,
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),

View File

@@ -168,6 +168,10 @@ export interface WagonTransferRequest {
closedShortByUserId?: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
/** The wagons the requester hand-picked, if any. A preference, not a reservation. */
preferredWagonIds?: string[] | null;
/** Those same picks resolved to wagon numbers by the API, for display. */
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
@@ -182,6 +186,8 @@ export interface CreateTransferRequestPayload {
quantity: number;
/** Mandatory: why the wagons are needed. */
reason: string;
/** Specific wagons the requester wants — at most `quantity` of them. */
preferredWagonIds?: string[];
note?: string;
}